Monstars

Monstars

Monstars is my most recent and most ambitious project that has been in full time development since March 2019. The inception of Monstars came as a result of my affinity towards the Pokémon franchise from an early age, playing the games on the Nintendo DS always made me wonder what a 3d realtime Pokémon-like multiplayer game would look like. Monstars aims to recreate this childhood dream by being its own monster capturing game that encompasses realtime combat, multiplayer functionality (100s of CCUs) the aim of which is to train your monsters, explore the world with friends and battle to become a stronger trainer.

I attempted to make this game early in my programming career and had to quit due to a lack of knowledge. I devoted a further year into solidifying my knowledge in C# and unity by creating smaller games such as my Cube World clone to the point where I felt comfortable creating tackling this idea I've wanted to create for years.

After over a years development, Monstars is nearing the end of the main programming side of the development cycle with most features being partially to fully implemented with networking support with art being the limiting factor. Some of these features include;

  • Catching a wide variety of monsters
  • Training monsters by battling wild monsters
  • Exploring, completing quests etc.
  • Battling other playes, NPCs etc.
  • Trading with other players
  • Forming guilds and establishing in world guild houses with other players
  • Creating structures (such as tents, cooking pots etc.)
  • Battling other players, trading, forming guilds, as your monsters get stronger

gif

Multiplayer

Multiplayer has been the key focus during the development cycle of monstars as it is a game intended to be played with friends. Monstars is built off vis2k's "uSurvival" networking framework which is developed using the Mirror networking solution for Unity. This framework has been heavily altered to allow support for controllable monsters and everything that comes with that (state and unique stat synchronization, trading, guilds, pvp just to mention a few). This has taught me a lot about networking in games, efficient networking principles and implementing client/server authentication to prevent exploitation of client side code.

Realtime Combat

The combat in Monstars is by no means simplistic, I envisioned combat similar to that seen in both the Pokémon anime and the Pokken game series. This means the combat is real-time where the user has full control of their monsters movement and attacks. Users can dynamically block, counter, dash, perform combos to defeat their opponent. This presented a lot of challenges on both the programming and networking side. ##Blocking - Combat mechanic

An example of one of these combat mechanics is the Blocking mechanic. The user can block at any time as long as they meet the requirements of the EventCanBlock() bool and has pressed the blockKey keycode in the IDLE or moving states.

public bool EventCanBlock()
    {
        return NetworkTime.time > nextBlockTime &&  //checks server side time against nextBlockTime which prevents spamming
            playerMonsterController.monsterData.currentStamina >= blockStaminaCost && //can only block if the user has enough stamina to
            playerMonsterController.monsterData.currentShieldHealth > 0; //can only block if their shield has enough health, to prevent spamming
    }

Which is called in the UpdateIDLE and UpdateWALKINGANDRUNNING FSM states.

public MonsterMoveState UpdateIDLE(Vector2 inputDir)
{
		if (EventBlockRequested()) //pressed the blockKey keycode this frame
	            {
	                if (EventCanBlock())
	                {
	                    LookAtTargetY(); //look at the target
	                    pokemonModel.transform.localRotation = Quaternion.identity; //reset the models local rotation
	                    OnBlockStarted?.Invoke(); //invoke block started event
	                    return PokemonMoveState.BLOCKING; //set the state to blocking
	                }
	                else //else didnt meet the requirements to block
	                {
	                    OnCantBlock?.Invoke(); //invoke a cant block event
	                }
	            }
			...
}

I use events to tie other components to this system, A separate MonsterCombat class is used to display these events visually to the user, the UI system also subscribes to these events to show visual indicators.

void Start()
    {
        var movement = (controller as PlayerPokemonController).monsterMovement;
        movement.OnBlockStarted += InvokeBlockStarted;
        movement.OnBlockedMove += InvokeBlockHit;
        movement.OnBlockEnded += InvokeBlockEnded;
        movement.OnCantBlock += InvokeCantBlock;
        movement.OnBlockBroken += InvokeBlockBroken;
        movement.OnReflectedMove += InvokeMoveReflected;
    }

When blocking, a visual shield will be shown infront of the monster that has health, different models to indicate its health. This shield is manipulated depending on what happened, if the user blocked a move it will flash red and play a hit tween, if the shield broke, then the model would be destroyed etc.

public void InitialiseShield() //called when pressing the block key
    {
        if (blockPrefabInstance) //ensure only one instance of shield
            Destroy(blockPrefabInstance);
        blockPrefabInstance = Instantiate(PokemonDataManager.Instance.blockPrefab);
        //set position in the correct position no matter the monster by basing its position by the height and radius of the collider
        blockPrefabInstance.transform.position = controller.transform.position + controller.pokemonModel.transform.forward.normalized * (controller.GetColliderRadius() * 1.5f) + Vector3.up * controller.GetColliderHeight() / 2f;
        blockPrefabInstance.transform.SetParent(controller.transform, true); //set parent so it moves relative to the monster
    }

I also use DOTween to display visual transformations of the shield when attacked

public void InvokeBlockHit(int damage)
    {
        blockPrefabInstance.transform.DOKill(true); //kill any current tweens on the shiled
        blockPrefabInstance.transform.localScale = defaultShieldScale; //reset scale
        blockPrefabInstance.transform.DOPunchScale(blockPrefabInstance.transform.localScale * 1.3f, 0.5f); //punch the scale to indicate hit
        blockPrefabInstance.transform.DOPunchPosition(-Vector3.forward * 0.5f, 0.5f); //punch position backwards to indicate hit
        var renderers = blockPrefabInstance.transform.GetChild(0).GetComponentsInChildren<MeshRenderer>(); //get each renderer (different shield health models)
        foreach (var renderer in renderers)
        {
            renderer.material.DOBlendableColor(Color.red, 0.3f).OnComplete(new TweenCallback(() =>
            renderer.material.DOBlendableColor(Color.white, 0.1f))); //flash the color red to indicate hit
        }
        controller.audioSource.PlayOneShot(blockHitSounds[Random.Range(0, blockHitSounds.Length)]); //play one of random block hit sound from predifined array
        CinemachineShake.Instance.ShakeCamera(damage / 20, 0.6f); //reference camera singleton to shake the camera to indicate hit
        var dir = (controller.targetMonster.transform.position - transform.position).normalized;
        transform.DOMove(transform.position - dir, 0.2f).SetEase(Ease.OutBack); //move the monster backwards to indicate hit
    }

All these events now need to be invoked at the correct time, we do this in the UpdateBLOCKING FSM update loop as well as when receiving an attack from the enemy monster during combat.

public MonsterMoveState UpdateBLOCKING()
    {
        LookAtTargetY(); //always look at the target on the Y axis
        moveDir.x = 0;
        moveDir.y = ApplyGravity(0); //still apply gravity incase fell off something or was midair when blocking
        moveDir.z = 0;
        if (EventBlockEnded()) //no longer pressing the block key
        {
            OnBlockEnded?.Invoke(); //invoke block ended
            return MonsterMoveState.IDLE;
        }
        if (EventReflectRequested() && EventCanReflectAgain()) //check for reflect input
        {
            reflectEndTime = NetworkTime.time + playerMonsterController.reflectingClipLength; //update reflectEndTime to prevent spamming
            return MonsterMoveState.REFLECTING;//return reflecting
        }
        return MonsterMoveState.BLOCKING;
    }
public void InterpretRecievedMove(int moveId, GameObject aggressor)
    {
        attackingMonster = aggressor; //update attacking monster
        MonsterMoveItem moveData = MonsterDataManager.Instance.monsterMoveDataByMoveID[moveId]; //get the MonsterMoveItem struct from the singleton manager
        //Interpret attack
        if (!EventMoveCanBeBlocked(moveId)) //checks if the move can be blocked
        {
            OnBlockBroken?.Invoke(moveData.penetrativeDamage); //invoke a block broken event
            state = MonsterMoveState.STUNNED; //update monster state
            stunEndTime = NetworkTime.time + moveData.stunLength; //set monster in the STUNNED state for the length of the moves stunLength field
        }
        else
        {
            if (EventIsBlocking())
            {
                animator.OnAnimatorRecieveAttack(); //play animator block hit animation
                OnBlockedMove?.Invoke(moveData.penetrativeDamage); //invoke move blocked event
                trainer.CmdShieldDecrement(moveData.penetrativeDamage); //take damage on the shield
                return;
            }
            else if(EventIsReflecting() && EventMoveCanBeReflected(moveData) //if we are in the reflecting state and the incoming move can be reflected
            {
                OnReflectedMove?.Invoke(); //invoke reflected event
                return;
            }

        } //else
        RpcOnAttackedSound(moveId); //play attack sound
        receivedMoveId = moveId; //update recievedMoveId [SyncVar]
        ReceiveDamage(moveId);  //take dmaage
				...
}

Projectiles can also be reflected off the shield as shown in the code, this is a check that occurs in the projectile script itself when it hits a shield

private void OnCollisionEnter(Collision collision)
    {
        if (collision.gameObject.tag == "Environment")
        {
            hitSomething = true;
            DestroyProjectile(3f);
        }
        if (collision.gameObject.tag == "Shield")
        {
            print("COLLISION WITH SHIELD");
            var normal = collision.contacts[0].normal;
            Debug.DrawLine(normal, normal * 1.5f, Color.blue, 5f);
            if (isAI)
            {
                var targetState = (owner as WorldPokemonController).fsm.targetPlayerPokemonController.monsterMovement.state;
                if (targetState == PokemonMoveState.REFLECTING && moveData.isReflectable)
                {
                    ProjectileReflectedByTarget();
                }
                else if (targetState == PokemonMoveState.BLOCKING)
                {
                    DestroyProjectile(1f);
                }
            }
				 }
		 }

private void ProjectileReflectedByTarget()
    {
        reflected = true;
        hitSomething = false;
        rb.velocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;
        var dir = (owner.headTransform.transform.position - transform.position).normalized;
        rb.AddForce(dir * moveData.projSpeed, ForceMode.Impulse); //reflect in opposite direction
        StartCoroutine(FlashColor(Color.green, 0.5f));
        StartCoroutine(DetectCollisionsInTime(0.1f));
        var tr = GetComponent<TrailRenderer>();
        if (tr)
        {
            tr.startColor = Color.green;
            tr.endColor = Color.green;
        }
    }

All this ties together to produce the result below. Block

#Finite State Machines Although PVP combat is one of the main features of Monstars, users should also be able to fight and capture wild monsters, this required extensive research into how FSMs work as well as other solutions such as G.O.A.P where different actions are ranked by priority of which is best suited for a multitude of enemy/player relational and environmental variables. I implemented aspects of G.O.A.P in Monstars (i.e. enemy monster should run away if it is out of stamina, close to the player and has low health). This taught me a lot about FSMs and how they can be used to bring the AI to life.

The FSM manages states by first storing reference to what state the monster is in it does this using an enum. Enums allow easy synchronization over the network as they are only 1 byte.


public enum MonsterMoveState state : byte {
  IDLE,WALKING,RUNNING,JUMPING,STUNNED,DASHINGAIRBORNE,FAINTED,ATTACKING,DODGING,FLYING,FASTFLYING,DIGGING,
  BLOCKING,REFLECTING,RECOVERING,
}

This states value is constantly checked in unitys fixed update loop, if the monsters state changes, then the code executed as a result will change, some states require the variables inputDir and desiredGroundDir, these are Vectors that are updated each frame that detect what movement keys the user is imputting, and where the mosnter should move in relation to that and the ground.


if (state == MonsterMoveState.IDLE) state = UpdateIDLE(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.WALKING) state = UpdateWALKINGandRUNNING(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.RUNNING) state = UpdateWALKINGandRUNNING(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.JUMPING) state = UpdateJUMPING(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.STUNNED) state = UpdateSTUNNED();
else if (state == MonsterMoveState.DASHING) state = UpdateDASHING();
else if (state == MonsterMoveState.AIRBORNE) state = UpdateAIRBORNE(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.DODGING) state = UpdateDODGING();
else if (state == MonsterMoveState.FLYING) state = UpdateFLYING(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.FASTFLYING) state = UpdateFLYING(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.DIGGING) state = UpdateDIGGING(inputDir, desiredGroundDir);
else if (state == MonsterMoveState.BLOCKING) state = UpdateBLOCKING();
else if (state == MonsterMoveState.ATTACKING) state = UpdateATTACKING(inputDir);
else if (state == MonsterMoveState.REFLECTING) state = UpdateREFLECTING();
else if (state == MonsterMoveState.RECOVERING) state = UpdateRECOVERING();
else if (state == MonsterMoveState.FAINTED) state = UpdateFAINTED();
else Debug.LogError
  ("Unhandled Movement State: " + state);
}

Now the corresponding UpdateSTATENAME method will be called, all of these methods follow the same deisgn pattern. We check for events we are interested in that would take us out of the current state, else we return the same state and the same update method will be called again next frame.

public MonsterMoveState UpdateATTACKING(Vector3 inputDir)
    {
        //variable attEndTime time is checked to see if the attack state should end (essentially the length of the attack animation)
        if (EventAttackTimeFinish())
            return MonsterMoveState.IDLE; //if so, return back to idle state
        else
        {
            //if we have been hit by the target and the move the target attacked us with is unblockable...
            if (EventHitByTarget() && EventTargetsAttackIsUnblockable())
                //then we invoke the OnStunned event, which has subscribers that do:
                //--Play a hit sound
                //--Display visual stun indicator on the UI
                //--increment the stunEndTime to the stunlength variable on the MonsterMoveData corresponding to the moveId recieved by the enemy
                OnStunned?.Invoke(targetMoveId);
                return MonsterMoveState.STUNNED; //return the stunned state
            return MonsterMoveState.ATTACKING; //else return attacking
        }
    }

© 2022 Made by Jack Fisher