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! 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!