DEMIGUN
Solo Project (2020 - Ongoing)
What is DEMIGUN?
DEMIGUN is an FPS rogue-like set in a Dark Fantasy world where you possess the world’s only gun, gifted to you by the god of guns. The DEMIGUN.
What makes the DEMIGUN special is it’s modularity. Blasting goblins n ghouls has a chance to drop gun mods that slot into your weapon, changing how it feels and plays entirely. For example, one run you could put together a laser-shotgun, the next an incendiary chicken-spawning egg launcher. Make the DEMIGUN as lethal or as goofy as you like. Items are also found which have additional effects on your character that aren’t confined to a slot, allowing for a surplus of combined and compounding effects on your character each run.
Being a rogue-like, levels are of course procedurally generated (See section 2). Each run through DEMIGUN’s various environments will have an entirely new layout.
DESIGN + CODE Samples
1: FPS Character + Dynamic gun sway
Player movement is an integral part of DEMIGUN’s design. I want the player’s movements to feel fluid and agile yet controlled. The key to great-feeling player movement lies very much in feedback.
Things such gun sway, increasing the FOV (Field of View) when sprinting and having the camera ‘dip’ upon the player landing after a fall or jump has a huge impact on the feel and juiciness of player movement.
The gun sway effect I’ve implemented here is directly tied to the player’s movements and velocity. The way the gun shifts and tilts corresponds to whether the player is rising, falling, or moving to the left or right. This interwovenness helps it to feel especially natural and responsive. When you bounce, the gun will bounce with you. It acts as a visualization of your movements.
Code Sample:
public void updateSway() { float mouseX = Input.GetAxis("Mouse X"); float mouseY = Input.GetAxis("Mouse Y"); float stepX = Input.GetAxis("Horizontal"); lrFactor = stepX / lrDiv; //lrDiv is to tweak the value Vector3 vel = playerManager.instance.player.GetComponent<Rigidbody>().velocity; //access singleton player instance Vector3 velN = vel.normalized; //---sway based on mouse movements Vector3 targetSwayPos = new Vector3(mouseY / 20 + rfFactor, mouseX / 20 + lrFactor, 0); //---move gun transform.localPosition = Vector3.Slerp(transform.localPosition, targetSwayPos, 0.08f); //---rotate gun float yT = riseTilt * vel.y; yT = Mathf.Clamp(yT, -20, 15); Quaternion t = Quaternion.Euler(0, yT, -lrFactor * 180f); transform.localRotation = Quaternion.Lerp(transform.localRotation, t, tiltSpeed); //change values based on how the player is moving if(velN.y < 0) //falling { rfFactor = vel.y / rfFDiv; tiltSpeed = 0.15f; } else if (velN.y > 0) //rising { rfFactor = -vel.y / (rfFDiv * 2.5f); tiltSpeed = 0.1f; } else //neutral { rfFactor = 0; tiltSpeed = 0.3f; } rfFactor = Mathf.Clamp(rfFactor, -1f, 1f); //clamp so it won't get too crazy }
2: Procedural Level Generation
DEMIGUN’s level generation is a blend between a grid and room-based system. Each level is comprised of a few arenas that allow for more interesting enclosed combat scenarios, which are spaced apart and connected via halls sprinkled with enemies. Some arenas have to be completed in order to progress and some will be optional, though which an arena is may not always be clear. This creates opportunities for exploration and avoids feelings of linearity.
A custom grid is generated at the start, where each cell can store information about whether or not they are occupied by a room, what that room is, and where on the grid it lies.
Dungeon rooms are also a custom class that defines their size, world position, and which room prefab to use during instantiation.
A summary of how the system is executed:
Generate a grid -> choose two cells to be the ‘start’ and ‘end’ of the dungeon -> place a number of (white/black gizmos) arenas (defined by the specific level) randomly around the grid -> Fill in the remaining space with connector rooms -> Path from the start cell to the closest arena entrance, then from the end to it’s closest -> Loop for however many arenas there are, leaving 1-2 spare, connecting them via their entrances. The pathfinding will go around arenas to find a specified entrance cell -> instantiate the room prefabs at dungeonRoom’s position -> Loop through all placed rooms and scatter enemies throughout via spawn points
-> place the player at the start.
I also added a ‘compass’ arrow that will always point toward the end of the dungeon to help keep players from getting lost.
Code Sample:
public dungeonRoom chooseRoom(int cellx, int celly) { dungeonRoom roomOutput = new dungeonRoom(); bool roomIsClear = false; while (roomIsClear == false) //loop until we find a room type that fits here { roomOutput = dungeon.roomList[Random.Range(0, dungeon.roomList.Count)]; //grab a random room from this dungeon's room list int count = 0; for (int y = 0; y < roomOutput.roomSize.y; y++) { for (int x = 0; x < roomOutput.roomSize.x; x++) //loop thru cells that this room would cover { if(cells[cellx + x, celly + y].occupied == true) //check these cells in cells array { count++; //cell is already occupied, restart loop } } } if(count == 0) { roomIsClear = true; } } return roomOutput; }
3: Enemy Behaviour System
Enemies inherit from either a basic melee or basic ranged behaviour. Down the line, this will allow me to add more unique behaviours to individual enemy types.
Both make use of the NavMeshAgent component, making it very easy to adjust the way they move and navigate the environment. A navmesh is generated at runtime due to the levels being procedurally generated.
Melee enemies will simply try to get within range of the player, attacks have a delay between them based on the enemy’s ‘attack speed’ stat.
Ranged enemies check both whether they are within range and if the player is visibile within their line of sight. If both are true, the enemy will stop pathing toward the player and shoot.
CODE SAMPLE:
public void meleeAttack() { attackSpeedCounter = 0; //reset attack speed navAgent.isStopped = true; //stop moving Vector3 attackPos = new Vector3(transform.position.x, transform.position.y + 0.5f, transform.position.z + 2f); //offset it to be in front of character if(Physics.CheckBox(attackPos, new Vector3(2, 1, 3), Quaternion.identity, LayerMask.GetMask("Player"))) { Debug.Log("melee hit player"); anim.SetTrigger("attack"); playerStats.instance.health -= attackDamage; } navAgent.isStopped = false; }