HGK Survival First Person Shooter

Hi!! ✧。٩(ˊᗜˋ )و✧*。
In this forum, I’ll show you how I created a survival FPS game using the Yahaha Horror Game Kit. It can be used to make games inspired by the Resident Evil series or Cry of Fear. But FPS is just one example. Instead of guns, you could use magic! There are so many possibilities. Let your imagination run wild and have fun!

GETTING STARTED
I’ve provided 6 video tutorials with a total runtime of about an hour. This forum is divided into 5 different sections, see the table of contents below for details. I’ve also shared the scripts for each section. Most of the scripts are designed so that you can use them right away after pasting, simply by adjusting their fields in the editor. That way, you can start creating immediately and watch the tutorials later if you want.

Note: The tutorials form a continuous series. Some things I did in earlier videos were changed in later ones, but I’ve included the finalized scripts here. And please ignore any small flaws in the video captions.

TABLE OF CONTENTS

  • Gun System
  • Create Reusable Hitbox
  • Enemy
    • Part 1: States, animations, and navigation
    • Part 2: Completing the remaining states
  • Player Health
  • Object Hit Points

Section 1 : Gun System

The gun is designed to detect exactly what object it’s shooting at, using a RayCast from the player’s camera. You can configure the gun directly from the inspector. For example, you can set the magazine size or the fire rate. I’ve also left room for gun upgrades so you can expand and customize it further.

Script .editor

local fieldDefs = {
    {
        name = "UIField",
        label = "UI Field",
        hint = "UI Field",
        description = "UI Field",
        type = "UIPackageFile"
    },
    { 
        name = "PanelName",
        type = "string",
        default = "GunUI",
    },
    {
        name = "AmmoCountText",
        type = "string",
        default = "AmmoCountText",
    },
    {
        name = "ReloadText",
        type = "string",
        default = "ReloadText",
    },
    {
        name = "ShootTrigger",
        type = "GameObject",
    },
    {
        name = "ReloadTrigger",
        type = "GameObject",
    },
    {
        name = "ReloadTrigger2",
        type = "GameObject",
    },
    {
        name = "BoolDisabledGunshot",
        type = "GameObject",
    },
    {
        name = "BoolAddRandomAmmo",
        type = "GameObject",
    },
    {
        name = "BoolAddAmmo",
        type = "GameObject",
    },
    {
        name = "AddAmmoCount",
        type = "integer",
    },
    {
         name = "UpgradeTriggerList",
        type = {
            type = "list",
            items = {
                name = "UpgradeTrigger",
                type = "GameObject",
            }
        }
    },
    {
        name = "GunName",
        type = "string",
        default = "Gun",
    },
    {
        name = "GunDamage",
        type = "integer",
        default = 1,
    },
    {
        name = "CurrentAmmo",
        type = "integer",
        default = 8,
    },
    {
        name = "MagazineSize",
        type = "integer",
        default = 8,
    },
    {
        name = "StartAmmoCount",
        type = "integer",
        default = 32,
    },
    {
        name = "ReloadSpeed",
        type = "float",
        default = 1.5,
    },
    {
        name = "FireRate",
        type = "float",
        default = 1,
    },
    {
        name = "HitObjectName",
        type = "string",
        default = "HitBox",
    },
    {
        name = "ShotTriggerName",
        type = "string",
        default = "ShotTrigger",
    }
}
script.DefineFields(fieldDefs)

Script

local UIField = script.fields.UIField
local PanelName = script.fields.PanelName
local ammoCountText = script.fields.AmmoCountText
local reloadText = script.fields.ReloadText
local shoot = script.fields.ShootTrigger
local reload = script.fields.ReloadTrigger
local reload2 = script.fields.ReloadTrigger2
local BoolDisabledGunshot = script.fields.BoolDisabledGunshot or nil
local GunName = script.fields.GunName
local ammoCount = script.fields.CurrentAmmo
local maxAmmoCount = script.fields.MagazineSize
local availableAmmo = script.fields.StartAmmoCount
local reloadSpeed = script.fields.ReloadSpeed
local fireRate = script.fields.FireRate
local UpgradeTriggerList = script.fields.UpgradeTriggerList or nil

local BoolAddRandomAmmo = script.fields.BoolAddRandomAmmo or nil
local GunDamage = script.fields.GunDamage
local BoolAddAmmo = script.fields.BoolAddAmmo or nil
local AddAmmoCount = script.fields.AddAmmoCount
local HitObjectName = script.fields.HitObjectName
local ShotTriggerName = script.fields.ShotTriggerName

local YaResourceManager = YahahaMiddleLayerSlim.Resource.YaResourceManager 

local itemSlotsManager = require("com.yahaha.sdk.props.scripts.ItemSlotsManager")
local co = require("Utils.CoroutineHelper")

local Input = UnityEngine.Input
local KeyCode = UnityEngine.KeyCode
local Time = UnityEngine.Time

local mainPanel, packageName
local cam = UnityEngine.Camera.main

local isReloading = false
local timeSinceLastShot = 0

-- Upgrade state
local hasUpgrade1 = false

script.OnStart(function ()
    LoadResource()
end)

function LoadResource()
    YaResourceManager.LoadResourceByUIPackageField(UIField, function(state, name)
        if state == AssetStatus.AllAssetCompleted then
            packageName = name
            CreateMainPanel()
        end
    end)
end

function CreateMainPanel()
    mainPanel = UIPackage.CreateObject(packageName, PanelName)
    GRoot.inst:SetContentScaleFactor(2436, 1125)
    GRoot.inst:AddChild(mainPanel)
    mainPanel.size = GRoot.inst.size
    mainPanel:AddRelation(GRoot.inst, RelationType.Size)

    ammoCountText = mainPanel:GetChild(ammoCountText)
    reloadText = mainPanel:GetChild(reloadText)
    reloadText.visible = false
    UpdateAmmoText()
end

function UpdateAmmoText()
    ammoCountText.text = tostring(ammoCount) .. " / " .. tostring(availableAmmo)
end

function Reload()
    if isReloading or ammoCount == maxAmmoCount or availableAmmo <= 0 then return end
    isReloading = true
    reloadText.visible = false
    shoot:SetActive(false)
    reload:SetActive(true)

    co.async(function()
        co.wait(reloadSpeed)
        reload2:SetActive(true)

        -- NEW: Calculate how much to reload based on available ammo
        local neededAmmo = maxAmmoCount - ammoCount
        local ammoToReload = math.min(neededAmmo, availableAmmo)

        ammoCount = ammoCount + ammoToReload
        availableAmmo = availableAmmo - ammoToReload

        UpdateAmmoText()
        isReloading = false

        co.async(function()
            co.wait(1)
            reload:SetActive(false)
            reload2:SetActive(false)
            timeSinceLastShot = 0
        end)
    end)
end

function Shoot()
    if isReloading or ammoCount <= 0 then
        reloadText.visible = true
        return
    end

    ammoCount = ammoCount - 1
    UpdateAmmoText()
    shoot:SetActive(true)

    co.async(function()
        co.wait(fireRate)
        shoot:SetActive(false)
    end)

    local ray = UnityEngine.Ray(cam.transform.position, cam.transform.forward)
    local maxLength = 1000
    local hit, result = UnityEngine.Physics.Raycast(ray, nil, maxLength, 13)

    if hit then
        local hitObject = result.collider.gameObject
        if hitObject.name == HitObjectName then
            print("Hit target object: " .. hitObject.name)
            local HitBox = script.GetLuaComponentByGameObject(hitObject, "HitBox")
            HitBox.TakeDamage(GunDamage)
        else
            print("Hit non-target object: " .. hitObject.name)
        end
    end
end

script.OnUpdate(function ()
    if BoolAddRandomAmmo and BoolAddRandomAmmo.activeInHierarchy then
        local randomAmmo = math.random(1, 3)
        availableAmmo = availableAmmo + randomAmmo
        BoolAddRandomAmmo:SetActive(false)
        UpdateAmmoText()
    end

    if BoolAddAmmo and BoolAddAmmo.activeInHierarchy then
        availableAmmo = availableAmmo + AddAmmoCount
        BoolAddAmmo:SetActive(false)
        UpdateAmmoText()
    end

    if UpgradeTriggerList and UpgradeTriggerList[1].activeInHierarchy and hasUpgrade1 == false then
        -- AddLogicHere 
        hasUpgrade1 = true
    end

    local item = itemSlotsManager:GetItem()
    if not item or item.itemName ~= GunName or not item.valid then return end

    -- Handle reload input
    if Input.GetKeyDown(KeyCode.R) then
        Reload()
    end

    -- Hold-to-fire logic
    if timeSinceLastShot < fireRate then
        timeSinceLastShot = timeSinceLastShot + Time.deltaTime
    end
    if Input.GetMouseButton(0) and not isReloading and not shoot.activeSelf and BoolDisabledGunshot.activeInHierarchy == false then
        if ammoCount > 0 and timeSinceLastShot >= fireRate then
            Shoot()
            timeSinceLastShot = 0
        elseif ammoCount <= 0 then
            reloadText.visible = true
        end
    end
    -- if Input.GetMouseButton(2) then -- Middle mouse held
        -- cam.fieldOfView = UnityEngine.Mathf.Lerp(cam.fieldOfView, zoomFOV, zoomSpeed * Time.deltaTime)
    -- else
        -- cam.fieldOfView = UnityEngine.Mathf.Lerp(cam.fieldOfView, defaultFOV, zoomSpeed * Time.deltaTime)
    -- end
end)

function Dispose()
    mainPanel:Dispose()
    Self:SetActive(false)
    YaResourceManager.RemoveResourceByUIPackageField(UIField)
end

Section 2 : HitBox

In this section, we’ll create a hitbox script to allow more flexible interactions. Objects can now have HP, and the script will process how much damage the gun deals.

Script .editor

local fieldDefs = {
    {
        name = "HitPointObj",
        type = "GameObject",
    },
    {
        name = "DamageMultiplier",
        type = "float",
        default = 1.0,
    },
    {
        name = "ShotTrigger",
        type = "GameObject",
    },
    {
        name = "DieTrigger",
        type = "GameObject",
    },
    {
        name = "ParentObj",
        type = "GameObject",
    },
    {
        name = "DestroyDelay",
        type = "float",
        default = 0,
    }
}
script.DefineFields(fieldDefs)

HitBox Script

local HitPointObj = script.fields.HitPointObj
local DieTrigger = script.fields.DieTrigger
local ShotTrigger = script.fields.ShotTrigger
local ParentObj = script.fields.ParentObj
local DestroyDelay = script.fields.DestroyDelay
local DamageMultiplier = script.fields.DamageMultiplier

local hitPointScript

local co = require("Utils.CoroutineHelper")
script.fields.takingDamage = false

script.OnStart(function ()
    hitPointScript = script.GetLuaComponentByGameObject(HitPointObj, "HitPoint")
end)
function TakeDamage(damage)
    takingDamage = true
    if hitPointScript.hitPoint > 0 then
        ShotTrigger:SetActive(true)
        hitPointScript.hitPoint = hitPointScript.hitPoint - (damage * DamageMultiplier)
        print("Damage dealt to HitBox: " .. damage * DamageMultiplier)
        print("HitBox took damage, remaining HitPoint: " .. hitPointScript.hitPoint)
        if hitPointScript.hitPoint <= 0 then
            hitPointScript.hitPoint = 0
            DieTrigger:SetActive(true)
            co.async(function()
                co.wait(DestroyDelay)
                ParentObj:Destroy()
                print("Object destroyed from scene")
            end)
        end
    end
    co.async(function()
        co.wait(0.5)
        ShotTrigger:SetActive(false)
        takingDamage = false
    end)
end

Section 3 : Enemy
Part 1

Part 2

In this part, we’ll create the enemy behaviors and navigation system. The enemy has six different states: Idle, Patrol, Chase, Attack, Staggered, and Dead. While the script isn’t the most flexible, it’s easy to use. The enemy also uses a navigation agent that handles pathfinding together with Yahaha’s NavMesh. You can adjust various enemy behaviors in the inspector, along with the NavAgent settings.

Script .editor

local fieldDefs = {
    { name = "EnemyModel", type = "GameObject"},
    { name = "HitBoxObj", type = "GameObject"},
    { name = "PlayerHealth", type = "GameObject"},
    { name = "PatrolOnStart", type = "boolean", default = true},
    { name = "WalkSpeed", type = "float", default = 0.5},
    { name = "RunSpeed", type = "float", default = 1.0},
    { name = "AggroRange", type = "float", default = 8.0},
    { name = "AttackRange", type = "float", default = 1.5},
    { name = "PatrolDistance", type = "float", default = 4.0},
    { name = "Damage", type = "integer", default = 10},
    { name = "DamageDelay", type = "float", default = 1},
    {
        name = "AnimationList",
        type = {
            type = "list",
            items = {
                name = "UpgradeTrigger",
                type = "string",
            },
        }
    },
    { name = "AggroTrigger", type = "GameObject"},
    { name = "AttackTrigger", type = "GameObject"},
    { name = "DieTrigger", type = "GameObject"},
    { name = "ShotTrigger", type = "GameObject"},
    {
        name = "BaseOffset",
        type = "float",
        default = 0,
    },
    {
        name = "AngularSpeed",
        type = "float",
        default = 180.0,
    },
    {
        name = "Acceleration",
        type = "float",
        default = 8,
    }
}
script.DefineFields(fieldDefs)

Enemy Script

local enemyModel = script.fields.EnemyModel
local hitBoxObj = script.fields.HitBoxObj
local WalkSpeed = script.fields.WalkSpeed
local RunSpeed = script.fields.RunSpeed
local AggroRange = script.fields.AggroRange
local AttackRange = script.fields.AttackRange
local PatrolOnStart = script.fields.PatrolOnStart
local PatrolDistance = script.fields.PatrolDistance
local AggroTrigger = script.fields.AggroTrigger
local AttackTrigger = script.fields.AttackTrigger
local DieTrigger = script.fields.DieTrigger
local ShotTrigger = script.fields.ShotTrigger
local PlayerHealth = script.fields.PlayerHealth
local damage = script.fields.Damage
local DamageDelay = script.fields.DamageDelay

local State = {
    Idle = 0,
    Patrol = 1,
    Chase = 2,
    Attack = 3,
    Staggered = 4,
    Dead = 5
}
local animationPlayer
local idleIndex, walkIndex, chaseIndex, attackIndex, staggeredIndex, deadIndex

local startPosition
local self = script.gameObject
local player = yahaha.GameFramework.GetGame():GetLocalPlayer()
local co = require("Utils.CoroutineHelper")
local navMeshAgent

local reachedDestination = true
local triggerChase = false
local selfPos
local playerPos
local distance
local hitBox
local playerHealth

script.OnStart(function ()
    startPosition = self.transform.position
    navMeshAgent = self:AddComponent(typeof(UnityEngine.AI.NavMeshAgent))
    navMeshAgent.baseOffset = script.fields.BaseOffset
    navMeshAgent.angularSpeed = script.fields.AngularSpeed
    navMeshAgent.acceleration = script.fields.Acceleration

    animationPlayer = script.GetLuaComponentByGameObject(enemyModel, "com.yahaha.sdk.animator.components.SimpleAnimationPlayComponent")
    hitBox = script.GetLuaComponentByGameObject(hitBoxObj, "HitBox")
    playerHealth = script.GetLuaComponentByGameObject(PlayerHealth, "PlayerHealth")
    idleIndex = GetAnimationIndex("Idle")
    walkIndex = GetAnimationIndex("Walk")
    chaseIndex = GetAnimationIndex("Chase")
    attackIndex = GetAnimationIndex("Attack")
    staggeredIndex = GetAnimationIndex("Staggered")
    deadIndex = GetAnimationIndex("Dead")
    
    if PatrolOnStart then
        currentState = State.Patrol
        animationPlayer.Play(walkIndex, 1)
        RandomPatrolPoint()
    else
        currentState = State.Idle
        animationPlayer.Play(idleIndex, 1)
    end
end)

function GetAnimationIndex(name)
    for i, animName in ipairs(script.fields.AnimationList) do
        if animName == name then
            return i
        end
    end
end

script.OnUpdate(function ()
    selfPos = self.gameObject.transform.position
    playerPos = player.gameObject.transform.position
    distance = (selfPos - playerPos).magnitude 
    if navMeshAgent == nil then
        return
    end
    if DieTrigger.activeInHierarchy then
        currentState = State.Dead
    end
    if (distance < AggroRange or hitBox.takingDamage) and triggerChase == false then
        AggroTrigger:SetActive(true)
        currentState = State.Chase
        triggerChase = true
    end
    HandleState()
end)


function HandleState()
    if currentState == State.Idle then
        navMeshAgent.speed = 0

    elseif currentState == State.Patrol then
        navMeshAgent.speed = WalkSpeed
        RandomPatrolPoint()
        if AnimationFinished(0.1) then
            animationPlayer.Play(walkIndex, 1)
        end

    elseif currentState == State.Chase then
        navMeshAgent.speed = RunSpeed
        Chase()

    elseif currentState == State.Attack then
        navMeshAgent.speed = 0
        Attack()

    elseif currentState == State.Staggered then
        navMeshAgent.speed = 0
        Staggered()

    elseif currentState == State.Dead then
        navMeshAgent.speed = 0
        Die()
    end
end

function RandomPatrolPoint()
    if reachedDestination then
        local randomX = math.random(startPosition.x - PatrolDistance, startPosition.x + PatrolDistance)
        local randomZ = math.random(startPosition.z - PatrolDistance, startPosition.z + PatrolDistance)
        local targetPosition = UnityEngine.Vector3(randomX, startPosition.y, randomZ)

        navMeshAgent:SetDestination(targetPosition)
        reachedDestination = false
    end
    if not navMeshAgent.pathPending and navMeshAgent.remainingDistance <= 0.1 and navMeshAgent.velocity.sqrMagnitude == 0 then
        reachedDestination = true
    end
end

local notStaggered = true

function Chase()
    if DieTrigger.activeInHierarchy then return end
    if ShotTrigger.activeInHierarchy and notStaggered then
        currentState = State.Staggered
        return
    end
    if distance <= AttackRange then
        currentState = State.Attack
        return
    end
    if AnimationFinished(0.1) then
        animationPlayer.Play(chaseIndex, 1)
    end
    navMeshAgent:SetDestination(playerPos)
end

local notAttacking = true

function Attack()
    if DieTrigger.activeInHierarchy then return end
    if ShotTrigger.activeInHierarchy and notStaggered then
        currentState = State.Staggered
        return
    end
    if notAttacking then
        AttackTrigger:SetActive(true)
        animationPlayer.Play(attackIndex, 1)
        co.async(function()
            co.wait(DamageDelay)
            playerHealth.TakeDamage(damage)
        end)
        notAttacking = false
    end
    if AnimationFinished(0.1) then
        AttackTrigger:SetActive(false)
        currentState = State.Chase
        notAttacking = true
    end
end

function Staggered()
    if DieTrigger.activeInHierarchy then return end
    if notStaggered then
        animationPlayer.Play(staggeredIndex, 1)
        notStaggered = false
    end
    if AnimationFinished(0.1) then
        currentState = State.Chase
        notStaggered = true
    end
end

local isDead = false
function Die()
    if isDead then
        return
    end
    animationPlayer.Play(deadIndex, 1)
    navMeshAgent.isStopped = true
    isDead = true
end

function AnimationFinished(earlyTime)
    local Controller = script.GetLuaComponentByGameObject(enemyModel, "com.yahaha.sdk.animator.components.YaPlayableController")
    local state
    if Controller then
        state = Controller.Layers[1].CurrentState
    end
    if not state then
        return false
    end
    -- End animation early if not smoothly transitioning
    if state.Length and state.Length > 0 and earlyTime > 0 then
        local earlyThreshold = 1 - (earlyTime / state.Length)
        return state.NormalizedTime >= earlyThreshold
    end
    return state.NormalizedTime >= 1
end

Section 4 : Player Health

In the Player Health section, the player can take damage from enemies, which updates the health bar and shows a hurt indicator. The player can also restore health—and how that happens is entirely up to you.
Script .editor

local fieldDefs = {
    {
        name = "UIField",
        label = "UI Field",
        hint = "UI Field",
        description = "UI Field",
        type = "UIPackageFile"
    },
    { name = "MaxHealth", type = "integer", default = 100},
    { name = "HurtTrigger", type = "GameObject"},
    { name = "DieTrigger", type = "GameObject"},
    {
        name = "BoolRestoreHealthList",
        type = {
            type = "list",
            items = {
                name = "BoolRestoreHealth",
                type = "GameObject",
            },
        }
    },
    {
        name = "RestoreHealtAmountList",
        type = {
            type = "list",
            items = {
                name = "RestoreHealtAmount",
                type = "integer",
            },
        }
    }

}
script.DefineFields(fieldDefs)

Player Health Script

local UIField = script.fields.UIField
local MaxHealth = script.fields.MaxHealth
local HurtTrigger = script.fields.HurtTrigger
local DieTrigger = script.fields.DieTrigger
local BoolRestoreHealthList = script.fields.BoolRestoreHealthList
local RestoreHealtAmountList = script.fields.RestoreHealtAmountList

local YaResourceManager = YahahaMiddleLayerSlim.Resource.YaResourceManager
local co = require("Utils.CoroutineHelper")

local mainPanel
local packageName

local healthBar
local hurtIndicator
local currentHealth

script.OnStart(function ()
    currentHealth = MaxHealth
    LoadResource()
end)

function LoadResource()
    YaResourceManager.LoadResourceByUIPackageField(UIField, function(state, name)
        if state == AssetStatus.AllAssetCompleted then
            packageName = name
            CreateMainPanel()
        end
    end)
end

function CreateMainPanel()
    mainPanel = UIPackage.CreateObject(packageName, "HealthUI")
    GRoot.inst:SetContentScaleFactor(2436, 1125)
    GRoot.inst:AddChild(mainPanel)
    mainPanel.size = GRoot.inst.size
    mainPanel:AddRelation(GRoot.inst, RelationType.Size)
    healthBar = mainPanel:GetChild("HealthBar")
    hurtIndicator = mainPanel:GetChild("HurtIndicator")
    UpdateHealthBar()
end

local isHealing = false
script.OnUpdate(function ()
    for i = 0, #BoolRestoreHealthList do
        local obj = BoolRestoreHealthList[i]
        if obj and obj.activeSelf and isHealing == false then
            isHealing = true
            local healAmount = RestoreHealtAmountList[i]
            RestoreHealth(healAmount)
            co.async(function()
                co.wait(0.5)
                isHealing = false
                obj:SetActive(false)
            end)
        end
    end
end)

function RestoreHealth(amount)
    currentHealth = math.min(currentHealth + amount, MaxHealth)
    UpdateHealthBar()
end


function UpdateHealthBar()
    if currentHealth then
        local healthPercentage = currentHealth / MaxHealth
        healthBar.value = healthPercentage * 100
    end
end

local isTakeDamage = false

function TakeDamage(damage)
    if isTakeDamage then return end
    isTakeDamage = true
    currentHealth = math.max(currentHealth - damage, 0)
    UpdateHealthBar()
    HurtTrigger:SetActive(true)
    hurtIndicator.visible = true
    co.async(function()
        co.wait(1)
        isTakeDamage = false
        HurtTrigger:SetActive(false)
        hurtIndicator.visible = false
    end)
    if currentHealth <= 0 then
        currentHealth = 0
        DieTrigger:SetActive(true)
        Dispose()
    end
end

function Dispose()
    mainPanel:Dispose()
    YaResourceManager.RemoveResourceByUIPackageField(UIField)
end

Section 5 : Object HitPoint

In this section, I’ve created a separate script to handle an object’s hit points. It can be used together with the HitBox script, allowing for more dynamic interactions and logic handling. I’ve also updated most of the other scripts in this part.
Script .editor

local fieldDefs = {
    { name = "HitPoint", type = "integer", }
}
script.DefineFields(fieldDefs)

HitPoint Script

local HitPoint = script.fields.HitPoint
script.fields.hitPoint = 0

script.OnStart(function ()
    hitPoint = HitPoint
end)

Your Turn to Create!
Congratulations on making it this far! :tada: I hope this guide helped you and that you had fun following along. Feel free to share what you’ve created here—I’d love to see what you’ve made!

1 Like