Jump to content

Lua Ultra Beginner's Guide #3 - making simple strategy game - importing animated models, AI, health bars


In this tutorial we will make a simple real-time tactic/strategy game in Ultra Engine.

Plan:

1. Download and import ground material, 2 characters models with animations and flag model

2. Create Unit component with bot behavior.

3. Add control over player units.

4. Making prefabs and new map

In this tutorial used 1.0.0 Engine version from Steam Beta branch (Dev in Standalone version).

Later versions, once they out, should be compitable code-wise. Just update shader via project sync in Project Manager in the Editor

Asset import

Load and unpack ground material from there in Materials\Ground folder 

Make in Models folder Characters in it Warrok and Paladin subfolders.

Now login into https://www.mixamo.com/

Find there Paladin character and dowload with these settings without animations to get T-Pose as standard bind pose:

image.thumb.png.90e1e6d6a610a8ea609e2791e38ed872.png

Now we need animations from the Sword And Shield series (type Shield into the search field on top to filter them): Idle, Run, lash, Impact, Death.

Hit "In Place" Checkbox to avoid character offset while animations:

image.png.f4ccf5797b69a1e0fc24dfdac3fc66ef.png

image.thumb.png.153e7b4888a8c6cf5dfae45df16ccfc5.png

Put these .fbx to Paladin folder. Convert them into .mdl with right click in project tab of the Editor if there were not already

 image.thumb.png.6acf89b26e89bdf12cfae129d39e3d6f.png

If material was not created correctly then take pngs textures from "Paladin WProp J Nordstrom.fbm" folder which was created by converted and put in Paladin folder
Convert them to DDS with Right Click. You can delete png and fbx files now. Now Paladin should look textured.
Rename "Paladin WProp J Nordstrom" intro "Paladin" just for convenience. Open Paladin.mdl with double click to open Model Viewer.

Let's load animations into this model from models with animations ("Sword And Shield Idle.mdl" etc):

image.png.d4231bc9175b2188060771cc31644882.png

We need rename those animations to use them properly later - open Model tab, select animation to rename. You can play it to find out which is what.

Lower will appear Animation panel where you can click on name to rename it:

image.png.fcfa2f959dbd7e2759c5c003ce708b6d.png

Let's called these animations: Idle, Attack, Pain, Death.

There is also animation with T-Pose which can be deleted with Tools->Remove Sequence.

We don't need animations files anymore and Paladin folder content should looks like that:

image.png.01b888d2a81f8c88cc92c3a4a76aa072.png

One more thing needs to be done for model - Collider. In View toggle Show Collider, open Model tab, select SKIN_MESH and in Physics choose Cylinder collider.

Offset and Size settings:

image.thumb.png.da1dc0544977e5d565b770f69dfa5349.png

We need worth enemy for our paladins - download Warrok model and animations from Mutant series - Idle, Run, Dying, Punch. For pain i choosed "Standing React Large From Right" animation.

Next do same stuff as it was with Paladin - material, animations etc.

In Transform make scale 0.85 so it would match Paladin size.

Collider settings:

image.png.9f199549e5ddf2962f675da2112d5bce.png

Another model that we need is a flag which would represent a place that player units will try to reach

It can be found here: https://sketchfab.com/3d-models/triangle-flag-adadd59fd56d44f7b0354d1caba38f95

Download .glb version and put it to new folder "triangle_flag"  in Models.

Convert to mdl in the Editor. By default this model is too small. Set Scale 5.0 in Transform tab and in Tools click Reset Transform (works properly atm only for non-animated models) to save new size for good.

Also click on Collapse in Tools to unite all meshes so it would be shown like single entity in a scene (otherwise this entity will have some kids which is not critical, but it's better to make it simpler).

image.thumb.png.fc3fa9f41d8353916249c02ef53b5565.png

triangle_flag.zip

Unit component

          After preparing character models we now need a component which will have:

  • Unit params: health, speed, damage etc.
  • Playing animations
  • Bot behaviour - move and attack nearby enemies
  • Input for player to move and attack something specific

Create in "Source\Components\AI" Unit.json and Unit.lua - can be done with + sign in the Editor

Unit.json:

{
  "component": {
    "properties": [
      {
        "name": "enabled",
        "label": "Enabled",
        "value": true
      },
      {
        "name": "isFullPlayerControl",
        "label": "Full Player Control",
        "value": false
      },
      {
        "name": "isPlayer",
        "label": "Is Player Unit",
        "value": false
      },
      {
        "name": "team",
        "label": "Team",
        "value": 1,
        "options": [
          "Neutral",
          "Good",
          "Bad"
        ]
      },
      {
        "name": "health",
        "label": "Health",
        "value": 100
      },
      {
        "name": "maxHealth",
        "label": "Max Health",
        "value": 100
      },
      {
        "name": "speed",
        "label": "Speed",
        "value": 3.0
      },
      {
        "name": "attackRange",
        "label": "Attack Range",
        "value": 2.0
      },
      {
        "name": "attackDamage",
        "label": "Attack Damage",
        "value": 30
      },
      {
        "name": "attackFrame",
        "label": "Attack Frame",
        "value": 5
      },
      {
        "name": "painCooldown",
        "label": "Pain Cooldown",
        "value": 1000
      },
      {
        "name": "decayTime",
        "label": "Decay Time",
        "value": 10000
      },
      {
        "name": "target",
        "label": "Target",
        "value": null
      },
      {
        "name": "targetPoint",
        "label": "Target Point",
        "value": null
      },
      {
        "name": "attackName",
        "label": "Attack Name",
        "value": "Attack"
      },
      {
        "name": "idleName",
        "label": "Idle Name",
        "value": "Idle"
      },
      {
        "name": "painName",
        "label": "Pain",
        "value": "Pain"
      },
      {
        "name": "deathName",
        "label": "Death",
        "value": "Death"
      },
      {
        "name": "runName",
        "label": "Run",
        "value": "Run"
      }
    ],
    "inputs": [
      {
        "name": "Enable"
      },
      {
        "name": "Disable"
      }
    ]
  }
}

Parameters descriptions can be found in Unit.lua:

Unit = {}
Unit.name = "Unit"
Unit.team = 0 -- 0 neutral, 1 player team, 2 enemy
Unit.isPlayer = false
-- so it could be added for entity with FPS Player component
Unit.isFullPlayerControl = false
Unit.health = 100
Unit.maxHealth = 100
-- used for AI navigation
Unit.navMesh = nil
-- NavAgent used to create to plot navigation paths in NavMesh
Unit.agent = nil
-- how far AI see its enemies in meters
Unit.perceptionRadius = 10
-- how long to pursue when out of radius
Unit.chaseMaxDistance = Unit.perceptionRadius * 2
-- is target a priority
Unit.isForcedTarget = false
-- target to follow and attack if possible
Unit.targetWeak = nil
-- to avoid fighting
Unit.isForcedMovement = false
-- which distance to point should be to reach it
Unit.targetPointDistance = 0.5
-- place to reach
Unit.targetPoint = nil
-- is attack animation playing
Unit.isAttacking = false
-- when attack started
Unit.meleeAttackTime = 0
-- do damage in meleeAttackTiming after attack start
Unit.attackFrame = 5
Unit.attackRange = 2.0
Unit.attackDamage = 30
-- pain/git hit state
Unit.isInPain = false
-- can't start new pain animation immediately to avoid infinite stugger
Unit.painCooldown = 300
-- when pain animation started
Unit.painCooldownTime = 0
-- how fast unit is
Unit.speed = 3.0
-- when to try scan again
Unit.nextScanForTargetTime = 0
-- health bar above unit
Unit.healthBar = nil
Unit.healthBarBackground = nil
Unit.isSelected = false
-- to keep camera pointer for unit health bars
Unit.camera = nil
-- to be able to remove entity inside of component later
Unit.sceneWeak = nil
-- time in ms before delete model after a death, 0 of disabled
Unit.decayTime = 10000
Unit.removeEntityTimer = nil
-- animations
Unit.attackName = "Attack"
Unit.idleName = "Idle"
Unit.painName = "Pain"
Unit.deathName = "Death"
Unit.runName = "Run"

function Unit:Start()
    local entity = self.entity
    local model = Model(entity)
    entity:AddTag("Unit")
    -- for custom save/load system
    entity:AddTag("Save")
    if (self.isFullPlayerControl == false) then
        if (self.navMesh ~= nil) then
            -- 0.5 m radius because of Beast long model, 0.5 would better otherwise, 2 m height
            self.agent = CreateNavAgent(self.navMesh, 0.5, 2)
            self.agent:SetMaxSpeed(self.speed)
            self.agent:SetPosition(entity:GetPosition(true))
            self.agent:SetRotation(entity:GetRotation(true).y)
            self.entity:SetPosition(0, 0, 0)
            -- becase models rotated by back
            self.entity:SetRotation(0, 180, 0)
            self.entity:Attach(self.agent)
        end
        entity:SetCollisionType(COLLISION_PLAYER)
        entity:SetMass(0)
        entity:SetPhysicsMode(PHYSICS_RIGIDBODY)
    end
    if (model ~= nil) then
        local seq = model:FindAnimation(self.attackName)
        if (seq ~= -1) then
            local count = model:CountAnimationFrames(seq)
            -- to disable attack state at end of attack animation
            model.skeleton:AddHook(seq, count - 1, Unit.EndAttackHook, self)
            -- to deal damage to target at range at specific animation frame
            model.skeleton:AddHook(seq, self.attackFrame, Unit.AttackHook, self)
        end
        seq = model:FindAnimation(self.painName)
        if (seq ~= -1) then
            local count = model:CountAnimationFrames(seq)
            -- to disable pain state at end of pain animation
            model.skeleton:AddHook(seq, count - 1, Unit.EndPainHook, self)
        end
        if (self.health <= 0) then
            seq = model:FindAnimation(self.deathName)
            local count = model:CountAnimationFrames(seq)
            model:Animate(self.deathName, 1.0, 250, ANIMATION_ONCE, count - 1)
        end
    end
    local world = entity:GetWorld()
    local cameras = world:GetTaggedEntities("Camera")
    for i = 1, #cameras do
        if (Camera(cameras[i]) ~= nil) then
            self.camera = Camera(cameras[i])
            break
        end
    end
    if (self.camera == nil) then
        local entities = world:GetEntities()
        for i = 1, #entities do
            if (Camera(entities[i]) ~= nil) then
                self.camera = Camera(entities[i])
                break
            end
        end
    end
    if (self.isFullPlayerControl == false) then
        local healthBarHeight = 5
        self.healthBar = CreateTile(self.camera, self.maxHealth, healthBarHeight)
        if (self.team == 1) then
            self.healthBar:SetColor(0, 1, 0)
        else
            self.healthBar:SetColor(1, 0, 0)
        end
        self.healthBar:SetPosition(0, 0)
        self.healthBarBackground = CreateTile(self.camera, self.maxHealth, healthBarHeight)
        self.healthBarBackground:SetColor(0.1, 0.1, 0.1)
        self.healthBar:SetScale(self.health / self.maxHealth, 1)
        -- to put it behind health bar
        self.healthBarBackground:SetOrder(0)
        self.healthBar:SetOrder(1)
    end
end

function Unit:Load(properties, binstream, scene, flags, extra)
    self.sceneWeak = scene
    self.navMesh = nil
    if #scene.navmeshes > 0 then
        self.navMesh = scene.navmeshes[1]
    end
    if type(properties.isFullPlayerControl) == "boolean" then
        self.isFullPlayerControl = properties.isFullPlayerControl
    end
    if type(properties.isPlayer) == "boolean" then
        self.isPlayer = properties.isPlayer
    end
    if type(properties.isSelected) == "boolean" then
        self.isSelected = properties.isSelected
    end
    if type(properties.team) == "number" then
        self.team = properties.team
    end
    if type(properties.health) == "number" then
        self.health = properties.health
    end
    if type(properties.maxHealth) == "number" then
        self.maxHealth = properties.maxHealth
    end
    if type(properties.attackDamage) == "number" then
        self.attackDamage = properties.attackDamage
    end
    if type(properties.attackRange) == "number" then
        self.attackRange = properties.attackRange
    end
    if type(properties.attackFrame) == "number" then
        self.attackFrame = properties.attackFrame
    end
    if type(properties.painCooldown) == "number" then
        self.painCooldown = properties.painCooldown
    end
    if type(properties.enabled) == "boolean" then
        self.enabled = properties.enabled
    end
    if type(properties.painCooldown) == "number" then
        self.painCooldown = properties.painCooldown
    end
    if type(properties.decayTime) == "number" then
        self.decayTime = properties.decayTime
    end
    if type(properties.target) == "string" then
        self.targetWeak = scene:GetEntity(properties.target)
    end
    if type(properties.isForcedMovement) == "boolean" then
        self.isForcedMovement = properties.isForcedMovement
    end

    if type(properties.attackName) == "string" then
        self.attackName = properties.attackName
    end
    if type(properties.idleName) == "string" then
        self.idleName = properties.idleName
    end
    if type(properties.deathName) == "string" then
        self.deathName = properties.deathName
    end
    if type(properties.painName) == "string" then
        self.painName = properties.painName
    end
    if type(properties.runName) == "string" then
        self.runName = properties.runName
    end
    return true
end

function Unit:Save(properties, binstream, scene, flags, extra)
    properties.isFullPlayerControl = self.isFullPlayerControl
    properties.isPlayer = self.isPlayer
    properties.isSelected = self.isSelected
    properties.team = self.team
    properties.health = self.health
    if (self.targetWeak ~= nil) then
        properties.target = self.targetWeak:GetUuid()
    end
    properties.health = self.health
    properties.isForcedMovement = self.isForcedMovement
end

function Unit:Damage(amount, attacker)
    if not self:isAlive() then
        return
    end
    self.health = self.health - amount
    local world = self.entity:GetWorld()
    if world == nil then
        return
    end
    local now = world:GetTime()
    if (self.health <= 0) then
        self:Kill(attacker)
    elseif (not self.isInPain and (now - self.painCooldownTime) > self.painCooldown) then
        self.isInPain = true
        self.isAttacking = false
        local model = Model(self.entity)
        if (model ~= nil) then
            model:StopAnimation()
            model:Animate(self.painName, 1.0, 100, ANIMATION_ONCE)
        end
        if (self.agent ~= nil) then
            self.agent:Stop()
        end
    end
    if (self.healthBar) then
        -- reducing health bar tile width
        self.healthBar:SetScale(self.health / self.maxHealth, 1)
    end
    -- attack an atacker
    if (self.isForcedMovement == false and self.isForcedTarget == false) then
        self:attack(attacker)
    end
end

function Unit:Kill(attacker)
    --need to remove pointer to other entity so it could be deleted
    self.targetWeak = nil
    local entity = self.entity
    if (entity == nil) then
        return
    end
    local model = Model(entity)
    if (model) then
        model:StopAnimation()
        model:Animate(self.deathName, 1.0, 250, ANIMATION_ONCE)
    end
    if (self.agent) then
        -- This method will cancel movement to a destination, if it is active, and the agent will smoothly come to a halt.
        self.agent:Stop()
    end
    -- to remove nav agent
    entity:Detach()
    self.agent = nil
    -- to prevent it being obstacle
    entity:SetCollisionType(COLLISION_NONE)
    -- to prevent selection
    entity:SetPickMode(PICK_NONE)
    self.isAttacking = false
    self.healthBar = nil
    self.healthBarBackground = nil
    if (self.decayTime > 0) then
        self.removeEntityTimer = CreateTimer(self.decayTime)
        ListenEvent(EVENT_TIMERTICK, self.removeEntityTimer, Unit.RemoveEntityCallback, self)
        -- not saving if supposed to be deleted anyway
        entity:RemoveTag("Save")
    end
end

function Unit:isAlive()
    return self.health > 0 and self.entity ~= nil
end

function Unit.RemoveEntityCallback(ev, extra)
    local unit = Component(extra)
    unit.removeEntityTimer:Stop()
    unit.removeEntityTimer = nil
    unit.sceneWeak:RemoveEntity(unit.entity)
    unit.sceneWeak = nil
    return false
end

function Unit:scanForTarget()
    local entity = self.entity
    local world = entity:GetWorld()
    if (world ~= nil) then
        -- We only want to perform this few times each second, staggering the operation between different entities.
        -- Pick() operation is kinda CPU heavy. It can be noticeable in Debug mode when too much Picks() happes in same game cycle.
        -- Did not notice it yet in Release mode, but it's better to have it optimized Debug as well anyway.
        local now = world:GetTime()
        if (now < self.nextScanForTargetTime) then
            return
        end
        self.nextScanForTargetTime = now + Random(100, 200)

        local entityPosition = entity:GetPosition(true)
        --simple copy would copy pointer to new var, so this case it's better recreate Vec3 here
        local positionLower = Vec3(entityPosition.x, entityPosition.y, entityPosition.z)
        positionLower.x = positionLower.x - self.perceptionRadius
        positionLower.z = positionLower.z - self.perceptionRadius
        positionLower.y = positionLower.y - self.perceptionRadius

        local positionUpper = Vec3(entityPosition.x, entityPosition.y, entityPosition.z)
        positionUpper.x = positionUpper.x + self.perceptionRadius
        positionUpper.z = positionUpper.z + self.perceptionRadius
        positionUpper.y = positionUpper.y + self.perceptionRadius
        -- will use it to determinate which target is closest
        local currentTargetDistance = -1
        -- GetEntitiesInArea takes positions of an opposite corners of a cube as params
        local entitiesInArea = world:GetEntitiesInArea(positionLower, positionUpper)
        for k, foundEntity in pairs(entitiesInArea) do
            local foundUnit = foundEntity:GetComponent("Unit")
            -- targets are only alive enemy units
            if not (foundUnit == nil or not foundUnit:isAlive() or not foundUnit:isEnemy(self.team) or foundUnit.entity == nil) then
                local dist = foundEntity:GetDistance(entity)
                if not (dist > self.perceptionRadius) then
                    -- check if no obstacles like walls between units
                    local pick = world:Pick(entity:GetBounds(BOUNDS_RECURSIVE).center,
                        foundEntity:GetBounds(BOUNDS_RECURSIVE).center, self.perceptionRadius, true, Unit.RayFilter,
                        self)
                    if (dist < 0 or currentTargetDistance < dist) then
                        self.targetWeak = foundEntity
                        currentTargetDistance = dist
                    end
                end
            end
        end
    end
end

function Unit:Update()
    if self.entity == nil or not self:isAlive() then
        return
    end
    local world = self.entity:GetWorld()
    local model = Model(self.entity)
    if world == nil or model == nil then
        return
    end
    if self.isFullPlayerControl then
        return
    end
    -- making health bar follow the unit
    local window = ActiveWindow()
    if (window ~= nil and self.healthBar ~= nil and self.healthBarBackground ~= nil) then
        local framebuffer = window:GetFramebuffer()
        local position = self.entity:GetBounds().center
        position.y = position.y + self.entity:GetBounds().size.y / 2 -- take top position of unit
        if (self.camera ~= nil) then
            -- ransorming 3D position into 2D
            local unitUiPosition = self.camera:Project(position, framebuffer)
            unitUiPosition.x = unitUiPosition.x - self.healthBarBackground.size.x / 2
            self.healthBar:SetPosition(unitUiPosition.x, unitUiPosition.y, 0.99901)
            self.healthBarBackground:SetPosition(unitUiPosition.x, unitUiPosition.y, 0.99902)
            local doShow = self.isSelected or (self.health ~= self.maxHealth and not self.isPlayer)
            self.healthBar:SetHidden(not doShow)
            self.healthBarBackground:SetHidden(not doShow)
        end
    end
    -- can't attack or move while pain animation
    if (self.isInPain == true) then
        return
    end
    local isMoving = false
    -- ignore enemies and move
    if (self.isForcedMovement == true and self:goTo()) then
        return
    end
    -- atacking part
    if (not isMoving) then
        local target = self.targetWeak
        -- Stop attacking if target is dead
        if (target ~= nil) then
            local distanceToTarget = self.entity:GetDistance(target)
            local doResetTarget = false
            if (distanceToTarget > self.chaseMaxDistance and ~self.isForcedTarget) then
                doResetTarget = true
            else
                for k, targetComponent in pairs(target.components) do
                    local targetUnit = Component(targetComponent)
                    if (targetUnit and not targetUnit:isAlive()) then
                        doResetTarget = true
                        self.isForcedTarget = false
                    end
                    break
                end
            end
            if (doResetTarget) then
                target = nil
                self.targetWeak = nil
                if (self.agent ~= nil) then
                    self.agent:Stop()
                end
            end

        end
        if (self.isAttacking and target ~= nil) then
            -- rotating unit to target
            local a = ATan(self.entity.matrix.t.x - target.matrix.t.x, self.entity.matrix.t.z - target.matrix.t.z)
            if (self.agent) then
                self.agent:SetRotation(a + 180)
            end
        end
        if (target == nil) then
            self:scanForTarget()
        end
        if (target ~= nil) then
            local distanceToTarget = self.entity:GetDistance(target)
            -- run to target if out of range
            if (distanceToTarget > self.attackRange) then
                if (self.agent ~= nil) then
                    self.agent:Navigate(target:GetPosition(true))
                end
                self.isAttacking = false
                model:Animate(self.runName, 1.0, 250, ANIMATION_LOOP)
            else
                if (self.agent) then
                    self.agent:Stop()
                end
                -- start attack if did not yet
                if (self.isAttacking == false) then
                    self.meleeAttackTime = world:GetTime()
                    model:Animate(self.attackName, 1.0, 100, ANIMATION_ONCE)
                    self.isAttacking = true
                end
            end
            return
        end
    end
    if (self.targetPoint ~= nil and self:goTo()) then
        return
    end
    if (self.isAttacking == false) then
        model:Animate(self.idleName, 1.0, 250, ANIMATION_LOOP)
        if (self.agent ~= nil) then
            self.agent:Stop()
        end
    end
end

function Unit.RayFilter(entity, extra)
    local thisUnit = Component(extra)
    local pickedUnit = entity:GetComponent("Unit")
    --skip if it's same team
    return pickedUnit == nil or pickedUnit ~= nil and pickedUnit.team ~= thisUnit.team
end


function Unit.AttackHook(skeleton, extra)
	local unit = Component(extra)
	if (unit == nil) then
		return
    end
	local entity = unit.entity
	local target = unit.targetWeak
	if (target ~= nil) then
		local pos = entity:GetPosition(true)
		local dest = target:GetPosition(true) + target:GetVelocity(true)
		--attack in target in range
		if (pos:DistanceToPoint(dest) < unit.attackRange) then
			for k, targetComponent in pairs(target.components) do
                if targetComponent.Damage and type(targetComponent.Damage) == "function" then
                    targetComponent:Damage(unit.attackDamage, entity)
				end
			end
		end
	end	
end

function Unit.EndAttackHook(skeleton, extra)
    local unit = Component(extra)
    unit.attacking = false
end

function Unit.EndPainHook(skeleton, extra)
    local unit = Component(extra)
    if (unit ~= nil) then
        unit.isInPain = false
        if (unit:isAlive() and unit.entity:GetWorld()) then
            unit.painCooldownTime = unit.entity:GetWorld():GetTime()
        end
    end
end

function Unit:isEnemy(otherUnitTeam) 
	return self.team == 1 and otherUnitTeam == 2 or self.team == 2 and otherUnitTeam == 1
end

function Unit:goToEntity(targetPointEntity, isForced) 
	if (targetPointEntity ~= nil) then
		self.isForcedMovement = isForced
		self.targetPoint = targetPointEntity
		self:goTo()
    end
end

function Unit:goToPosition(positionToGo, isForced) 
	if (self.entity ~= nil) then
		self.isForcedMovement = isForced
		self.targetPoint = CreatePivot(self.entity:GetWorld())
		self.targetPoint:SetPosition(positionToGo)
		self:goTo()
    end
end

function Unit:goTo() 
	local doMove = false
	local model = Model(self.entity)
	if (self.targetPoint ~= nil and self.agent ~= nil) then
		doMove = self.agent:Navigate(self.targetPoint:GetPosition(true), 100, 2.0)
        --doMove is false if unit can't reach target point
		if (doMove) then
			--checking distance to target point on nav mesh
			local distanceToTarget = self.entity:GetDistance(self.agent:GetDestination(true))
            local resultMaxDistance = self.targetPointDistance
            if (self.isForcedMovement) then
                --so that the player's units don't push each other trying to reach the point
                resultMaxDistance = resultMaxDistance * 2
            end
            --stop moving to this point if close enough
			if (distanceToTarget < resultMaxDistance) then
				local wayPoint = self.targetPoint:GetComponent("WayPoint")
				if (wayPoint ~= nil and wayPoint.nextPoint ~= nil) then
                    --move to next one if exist
					self.targetPoint = wayPoint.nextPoint
					doMove = true
				else 
					self.targetPoint = nil
					doMove = false
                end
			else
				doMove = true
            end
		end	
		if (doMove and model) then
			model:Animate(self.runName, 1.0, 250, ANIMATION_LOOP)
        end
	end
	return doMove
end

function Unit:attack(entityToAttack, isForced)
	self.targetWeak = nil
	if (entityToAttack == nil or entityToAttack:GetComponent("Unit") == nil or entityToAttack:GetComponent("Unit").team == self.team) then
		return
    end
	self.targetPoint = nil
	self.isForcedMovement = false
	self.isForcedTarget = isForced
	self.targetWeak = entityToAttack
end

function Unit:select(doSelect) 
	self.isSelected = doSelect
end

RegisterComponent("Unit", Unit)
return Unit

For Unit class you need WayPoint component from previous tutorial. Also can be download here:

https://www.ultraengine.com/community/files/file/3491-lua-waypoint/

Unit files: Unit.zip

 

Strategy Controller component

          Strategy Controller will be used to control player units:

  • Selecting a unit by left click, doing it with Control will add new unit to already selected
  • Clicking on something else reset unit selection
  • Holding left mouse button will create selection box that will select units in it once button released
  • Right click to make units go somewhere ignoring enemies or attacking specific enemy 

Its path will be: "Source\Components\Player"

StrategyController.json:

{
    "component":
    {
      "properties": 
      [
        {
          "name": "playerTeam",
          "label": "Player Team",
          "value": 1
        }
      ]
    }
}

StrategyController.lua

StrategyController = {}
StrategyController.name = "StrategyController"
StrategyController.selectedUnits = {}
-- Control key state
StrategyController.isControlDown = false
StrategyController.playerTeam = 1
StrategyController.camera = nil
StrategyController.unitSelectionBox = nil
-- first mouse position when Mouse Left was pressed
StrategyController.unitSelectionBoxPoint1 = iVec2(0, 0)
-- height of selection box
StrategyController.selectHeight = 4
-- mouse left button state
StrategyController.isMouseLeftDown = false

function StrategyController:Start()
    self.entity:AddTag("StrategyController")
    self.entity:AddTag("Save") -- For custom save/load system

    -- Listen() needed for calling ProcessEvent() in component when event happen
    self:Listen(EVENT_MOUSEDOWN, nil)
    self:Listen(EVENT_MOUSEUP, nil)
    self:Listen(EVENT_MOUSEMOVE, nil)
    self:Listen(EVENT_KEYUP, nil)
    self:Listen(EVENT_KEYDOWN, nil)

    -- optimal would be setting component to a camera
    if Camera(self.entity) ~= nil then
        self.camera = Camera(self.entity)
    else
        -- Otherwise, find the camera by tag
        for _, cameraEntity in ipairs(self.entity:GetWorld():GetTaggedEntities("Camera")) do
            self.camera = Camera(cameraEntity)
            break
        end
    end

    -- Create a 1x1 sprite for pixel-accurate scaling
    self.unitSelectionBox = CreateTile(self.camera, 1, 1)

    -- Set the sprite's color to transparent green
    self.unitSelectionBox:SetColor(0, 0.4, 0.2, 0.5)
    self.unitSelectionBox:SetPosition(0, 0, 0.00001)
    self.unitSelectionBox:SetHidden(true)

    -- Create a material for transparency
    local material = CreateMaterial()
    material:SetShadow(false)
    material:SetTransparent(true)
    -- material:SetPickMode(false)

    -- Use an unlit shader family
    material:SetShaderFamily(LoadShaderFamily("Shaders/Unlit.fam"))

    -- Assign the material to the sprite
    self.unitSelectionBox:SetMaterial(material)
end

function StrategyController.RayFilter(entity, extra)
    local pickedUnit = nil
    if (entity ~= nil) then
        pickedUnit = entity:GetComponent("Unit")
    end
	--skip if it's unit
	return pickedUnit == nil
end

function StrategyController:Load(properties, binstream, scene, flags, extra)
    if type(properties.playerTeam) == "number" then
        self.playerTeam = properties.playerTeam
    end
    for k in pairs (self.selectedUnits) do
        self.selectedUnits[k] = nil
    end
    if type(properties.selectedUnits) == "array" then
		for i = 1, #properties.selectedUnits do
			local unit = scene:GetEntity(properties.selectedUnits[i]);
			if (unit ~= nil) then
				self.selectedUnits[#self.selectedUnits+1] = unit;
            end
		end
    end
    return true
end

function StrategyController:Save(properties, binstream, scene, flags, extra)
    properties.playerTeam = self.playerTeam
    properties.selectedUnits = {};
	for i=1, #self.selectedUnits do
        properties.selectedUnits[i] = self.selectedUnits[i]:GetUuid()
    end
    return true
end

function StrategyController:ProcessEvent(e)
    local window = ActiveWindow()
    if not window then
        return true
    end

    local mousePosition = window:GetMousePosition()

    if e.id == EVENT_MOUSEDOWN then
        if not self.camera then
            return
        end
        if e.data == MOUSE_LEFT then
            self.unitSelectionBoxPoint1 = iVec2(mousePosition.x, mousePosition.y)
            self.isMouseLeftDown = true
            -- Move or attack on Right Click
        elseif e.data == MOUSE_RIGHT then
            -- Get entity under cursor
            local pick = self.camera:Pick(window:GetFramebuffer(), mousePosition.x, mousePosition.y, 0, true)
            if pick.success and pick.entity then
                local unit = pick.entity:GetComponent("Unit")
                if unit and unit:isAlive() and unit.team ~= self.playerTeam then
                    -- Attack the selected entity
                    for _, entityWeak in ipairs(self.selectedUnits) do
                        local entityUnit = entityWeak
                        if entityUnit and entityUnit:GetComponent("Unit") then
                            entityUnit:GetComponent("Unit"):attack(pick.entity, true)
                        end
                    end
                --checking if we have selected units
                elseif (#self.selectedUnits ~= 0) then
                    local flag = LoadPrefab(self.camera:GetWorld(), "Prefabs/FlagWayPoint.pfb")
                    if (flag ~= nil) then
						flag:SetPosition(pick.position)
					end
                    -- Move to the selected position
                    for _, entityWeak in ipairs(self.selectedUnits) do
                        local entityUnit = entityWeak
                        if entityUnit and entityUnit:GetComponent("Unit") then
                            if (flag ~= nil) then
                                entityUnit:GetComponent("Unit"):goToEntity(flag, true)
                            else
                                entityUnit:GetComponent("Unit"):goToPosition(pick.position, true)

                            end
                        end
                    end
                end
            end
        end
    elseif e.id == EVENT_MOUSEUP then
        if not self.camera then
            return
        end
        -- Unit selection on Left Click
        if e.data == MOUSE_LEFT then
            if not self:selectUnitsByBox(self.camera, window:GetFramebuffer(), iVec2(mousePosition.x, mousePosition.y)) then
                local pick = self.camera:Pick(window:GetFramebuffer(), mousePosition.x, mousePosition.y, 0, true)
                if pick.success and pick.entity then
                    local unit = pick.entity:GetComponent("Unit")
                    if unit and unit.isPlayer and unit:isAlive() then
                        if not self.isControlDown then
                            self:deselectAllUnits()
                        end
                        table.insert(self.selectedUnits, pick.entity)
                        unit:select(true)
                    else
                        self:deselectAllUnits()
                    end
                else
                    self:deselectAllUnits()
                end
            end
            self.isMouseLeftDown = false
        end
    elseif e.id == EVENT_KEYUP then
        if e.data == KEY_CONTROL then
            self.isControlDown = false
        end

    elseif e.id == EVENT_KEYDOWN then
        if e.data == KEY_CONTROL then
            self.isControlDown = true
        end
    end

    return true
end

function StrategyController:deselectAllUnits()
    for _, entityWeak in ipairs(self.selectedUnits) do
        local entityUnit = entityWeak
        if entityUnit and entityUnit:GetComponent("Unit") then
            entityUnit:GetComponent("Unit"):select(false)
        end
    end
    self.selectedUnits = {}
end

function StrategyController:Update()
    if not self.isMouseLeftDown then
        self.unitSelectionBox:SetHidden(true)
    else
        local window = ActiveWindow()
        if window then
            local mousePosition = window:GetMousePosition()
            local unitSelectionBoxPoint2 = iVec2(mousePosition.x, mousePosition.y)
            local upLeft = iVec2(math.min(self.unitSelectionBoxPoint1.x, unitSelectionBoxPoint2.x), math.min(self.unitSelectionBoxPoint1.y, unitSelectionBoxPoint2.y))
            local downRight = iVec2(math.max(self.unitSelectionBoxPoint1.x, unitSelectionBoxPoint2.x), math.max(self.unitSelectionBoxPoint1.y, unitSelectionBoxPoint2.y))
            -- Don't show the selection box if it's too small (could be a single click)
            if (downRight.x - upLeft.x < 4) or (downRight.y - upLeft.y < 4) then
                self.unitSelectionBox:SetHidden(true)
                return
            end
            -- Set the position of the selection box
            self.unitSelectionBox:SetPosition(upLeft.x, upLeft.y)
            -- Calculate the width and height of the selection box
            local width = downRight.x - upLeft.x
            local height = downRight.y - upLeft.y
            -- Change the sprite size via scale (size is read-only)
            self.unitSelectionBox:SetScale(width, height)
            -- Make the selection box visible
            self.unitSelectionBox:SetHidden(false)
        end
    end
end

function StrategyController:selectUnitsByBox(camera, framebuffer, unitSelectionBoxPoint2)
    if not self.unitSelectionBox or self.unitSelectionBox:GetHidden() or not camera or not framebuffer then
        return false
    end

    -- Calculate the top-left and bottom-right corners of the selection box
    local upLeft = iVec2(Min(self.unitSelectionBoxPoint1.x, unitSelectionBoxPoint2.x), Min(self.unitSelectionBoxPoint1.y, unitSelectionBoxPoint2.y))
    local downRight = iVec2(Max(self.unitSelectionBoxPoint1.x, unitSelectionBoxPoint2.x),  Max(self.unitSelectionBoxPoint1.y, unitSelectionBoxPoint2.y))

    -- Perform raycasting at the corners of the selection box
    local pick1 = camera:Pick(framebuffer, upLeft.x, upLeft.y, 0, true, StrategyController.RayFilter, nil)
    local pick2 = camera:Pick(framebuffer, downRight.x, downRight.y, 0, true, StrategyController.RayFilter, nil)

    if not pick1.success or not pick2.success then
        return false
    end

    -- Deselect all currently selected units
    self:deselectAllUnits()

    -- Calculate the lower and upper bounds of the selection area
    local positionLower = Vec3(
        math.min(pick1.position.x, pick2.position.x),
        math.min(pick1.position.y, pick2.position.y),
        math.min(pick1.position.z, pick2.position.z)
    )
    local positionUpper = Vec3(
        math.max(pick1.position.x, pick2.position.x),
        math.max(pick1.position.y, pick2.position.y) + self.selectHeight,
        math.max(pick1.position.z, pick2.position.z)
    )

    -- Find entities within the selection area
    for _, foundEntity in ipairs(camera:GetWorld():GetEntitiesInArea(positionLower, positionUpper)) do
        local foundUnit = foundEntity:GetComponent("Unit")
        -- Only select alive, player-controlled, and enemy units
        if foundUnit and foundUnit:isAlive() and foundUnit.isPlayer and foundUnit.team == self.playerTeam then
            table.insert(self.selectedUnits, foundUnit.entity)
            foundUnit:select(true)
        end
    end

    return true
end

RegisterComponent("StrategyController", StrategyController)

return StrategyController

StrategyController.zip

 

Also we need top down camera component, you can find it here, if you don't have it yet:

Prefabs

Create Prefabs folder in project root folder.

Open the Editor, add camera to empty scene, call it StrategyCamera and add to it TopDownCamera and StrategyController components.

You might also want to change a FOV in Camera tab in entity properties.

To make a prefab do right click on camera in Scene tab and press "Save as Prefab":

image.thumb.png.2d07168c630263141f1f073c0309d778.png

Once you want to changing something without having to do it at every map, just open prefab .pfb file and do a change there.

Now create Units subfolder in Prefabs folder for two units.

Add to scene Paladin model. Add Paladin component, click on "Is Player Unit" checkbix and change Attack Frame to 40 as it's a frame when sword will hit a target:

image.png.6f2f7becef2541b436e3e6aaab560224.png

Save as Prefab in Pefabs/Units as Paladin.pfb

You can remove now Paladin from scene and add Warrok. In its Unit component team will be Bad, health and max health 120, speed 2.0, attack range 1.5, attack damage 25, and Attack Frame 22 as it's attack faster.

image.png.0aac9515a6c31db485436f2b59007acc.png

Flag prefab:

1. Put triangle_flag model to scene

2. In Physics Collision type - None and Pick Mode - None

3. Attach WayPoint component

4. In Appearance tab in add "Save" to tags - it will be needed eventually for game save/load system

5. Save as prefab called FlagWayPoint in Prefabs folder

image.png.348a5a6537c6aa84d1c6c6364f06b569.png

FlagWayPoint.zip

Simple Strategy map creation

  1. Create big flat brush as a ground
  2. Add Navigation map - change Agent Radius to 0.5 and Agent Height 2.0 so Warrok could fit it. Tile size be default is 8 m² = Voxel size (0.25) * Tile Resolution (32). It's better not to touch those until you know what you are doing. To change Nav Mesh size just change Tile count in first line to make it fit created ground. For 40 m² it will be 5x5 tiles.
  3. Drag a ground material from Project tab "\Materials\Ground\Ground036.mat" to the ground. If it's blurry, select brush, choose Edit Face mode and increase a scale there.
  4. Select translate mode and drag'n'drop to scene prefabs StrategyCamera, Warrok and Paladin. You can copy entities with Shift + dragging.
  5. To edit a prefab entity you will need to "break" prefab. To do it click on the Lock icon in properties and Ok in the dialog.
  6. Also you need to break prefab to make component get real Scene, because otherwise in it will be prefab scene in Component's Load()
  7. image.png.161d3c9bbe22c8324dcc0259a4b54f99.png
  8. Simple map is ready. You can make it a little bit complicated by adding couple cycled WayPoints for Warroks. Add WayPoint to Target Point.

strategy.zip

 

In Game.lua update GameMenuButtonCallback function to avoid cursor being hidden:

local function GameMenuButtonCallback(Event, Extra)
    if (KEY_ESCAPE == Event.data and Extra) then
        local game = Extra
        local isHidden = game.menuPanel:GetHidden()
        game.menuPanel:SetHidden(not isHidden)
        if (game.player ~= nil) then
            if isHidden then
                window:SetCursor(CURSOR_DEFAULT)
            else
                window:SetCursor(CURSOR_NONE)
            end
            -- to stop cursor reset to center when menu on
            game.player.doResetMousePosition = not isHidden
        end
    end
    return false
end

Game.lua

 

Also replace in main.lua "start.ultra" with "strategy.ultra"

 

In result should be something like that:

Final version here: 

https://github.com/Dreikblack/LuaTutorialProject/tree/3-making-strategy-game

image.png

  • Like 1

1 Comment


Recommended Comments

WazMeister

Posted

In my honest opinion, it should have a new title.  Great content, and one day I may even understand half of it.. but it's definitely not 'beginner' level with that amount of code with no explanations. It may put newcomers off, thinking if that's the stuff beginners need to be learning and doing from the off beat....it gave me anxiety looking at it!

 

Other than that, amazing - keep up the great work!

Guest
Add a comment...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...