Jump to content

Recommended Posts

Posted

In this thread I will explain the differences between the Leadwerks 4 approach to object scripts, and in Leadwerks 5 the component-based approach and the entity-based approach.

Bullet Example

Let's consider code for a bullet. Each frame the bullet performs a ray cast. If the pick operation hits an entity and the entity appears to be alive, then damage is applied to that entity.

Script Object (Leadwerks 4)

In Leadwerks 4 we have a single table associated with the entity where user-defined properties are stored. The need to continuously type entity.script and self.entity was tedious and caused some confusion. For example, in the code below,  self.owner indicates the player that shot the bullet, but is it an entity or the entity's script object? (I don't actually know as I type this, and would have to go back and look through the source code to find out):

local result = self.entity.world:Pick(pos, targetpos, pickinfo, self.pickradius, true)
if result then
	if isfunction(pickinfo.entity.script.Damage) then
		pickinfo.entity.script:Damage(self.damage, self.owner)
	end
end

Component Properties

With the component approach we have tried, you can either search for one specific component by name, or loop through all the components and look for the Damage function:

local pick = self.entity.world:Pick(pos, targetpos, self.pickradius, true)
if pick.entity then
	local n
	for n = 1, #pick.entity.components do
		if isfunction(pick.entity.components[n].Damage) then
			pick.entity.components[n]:Damage(self.damage, self.owner)      		
		end
	end
end

During development of the FPS example I have seen several problems with this:

  1. Getting a property, setting a property, or calling a function on another entity involves a lot of extra code and is tedious.
  2. Two different scripts on the same entity might have a property with the same name, with different values. If the value is retrieved from the entity, then this can lead to objects being treated in unexpected ways.
  3. The order scripts are executed in can sometimes cause problems. While it is possible to add a script priority or other mechanism of sorting them, it's easy to make mistakes that lead to unexpected behavior, and these issues are hard to identify.
  4. The alternative to using a loop is to explicitly define which component you are looking for using GetComponent(name). The problem with this is that now all your scripts have to be written with an explicit declaration of every other script it might interact with. This greatly reduces interoperability of scripts and can require a large amount of code changes when you add new scripts to your game. If you try to subdivide common properties into more abstract components, the idea of modular characteristics breaks down under very simple scenarios, as I will explain below.
  5. Debugging is slower because you have to search for properties in several different subobjects and drill down further.

Entity Properties

I am testing another approach to Lua to simplify usage and solve these problems. Originally this is how I wanted scripts to work in Leadwerks 4, but it was not possible with the tolua++ binding library I was using at the time. With this approach properties are defined directly on the entity itself, so there is no need for any separate table to store them:

local pick = self.world:Pick(pos, targetpos, self.pickradius, true)
if pick.entity then
	if isfunction(pick.entity.Damage) then
		pick.entity:Damage(self.damage, self.owner)
	end
end

This approach involves a lot less typing of long strings like "self.owner.components[1].team" or even the stuff we saw in Leadwerks 4 like "self.owner.script.team". We can also know without even checking that self.owner is definitely an entity, since it wouldn't be anything else.

Where Modular Behavior Breaks Down

The component approach promises to provide mix-and-match entity behaviors that can make any combination of characteristcs. Although this sounds appearling, in practice I have found that very few entity behaviors can be executed in isolation. In fairly simple situations I have seen the promised modularity of the component approach break down.

Scenario 1: Object Destruction

Let's say we want to make a breakable crate using components. The component Collide function will detect a collision with a speed over a certain threshold. If this speed is hit, a glass shattering sound is played, several fragment models are spawned, and the original object is hidden:

function Breakable:Collide(collidedentity, position, normal, speed)
	if speed > self.threshold then
		if self.frag1model then
			self.frag1 = self.frag1model:Instantiate(self.entity.world)
			self.frag1:SetMatrix(self.entity.matrix)
			self.frag1:SetHidden(false)
		end
		if self.frag1mode2 then
			self.frag2 = self.frag2model:Instantiate(self.entity.world)
			self.frag2:SetMatrix(self.entity.matrix)
			self.frag2:SetHidden(false)
		end
		if self.frag1mode3 then
			self.frag3 = self.frag3model:Instantiate(self.entity.world)
			self.frag3:SetMatrix(self.entity.matrix)
			self.frag3:SetHidden(false)
		end		
		self.entity:EmitSound(self.breaksound)
		self.entity:SetHidden(true)
	end
end

Okay, now let's say we want this object to also emit a clinking noise when normal collisions occur. This sounds like a perfect job for modular components:

function ImpactNoise:Collide(collidedentity, position, normal, speed)
    if speed > self.minspeed then
        self.entity:EmitSound(self.sound) end
    end
end

At first I thought this would be great, but there's a problem. When the object breaks, both the impact sound and the shattering sound will be played, since both scripts will be run.

  • You could make it so the ImpactNoise script doesn't play the sound if the object is hidden, but if the ImpactNoise script executes first, that won't work.
  • You could add a max velocity value in the ImpactNoise script, but now you have two separate values you have to always make sure are in sync with each other.
  • You could implement some kind of sound manager class with sound priorities, but that turns something very simple into something very complicated. It should not require a lot of code to do such basic things.
  • We could just not worry about it because it is too hard to solve, and let both sounds play. Now the code is out of our control. This reminds me of Vulkan programming.

Even in this simple situation, modular behavior is not so simple as it seems at first. When I imagine more complex game interactions that are harder to analyze, I become very concerned about this approach.

Using the entity properties approach, the problem is easily solved with the addition of two lines of code:

function Breakable:Collide(collidedentity, position, normal, speed)
	if speed > self.threshold then
		if self.frag1model then
			self.frag1 = self.frag1model:Instantiate(self.world)
			self.frag1:SetMatrix(self.matrix)
			self.frag1:SetHidden(false)
		end
		if self.frag1mode2 then
			self.frag2 = self.frag2model:Instantiate(self.world)
			self.frag2:SetMatrix(self.matrix)
			self.frag2:SetHidden(false)
		end
		if self.frag1mode3 then
			self.frag3 = self.frag3model:Instantiate(self.world)
			self.frag3:SetMatrix(self.matrix)
			self.frag3:SetHidden(false)
		end		
		self:EmitSound(self.breaksound)
		self:SetHidden(true)
-----------------------------------------------------------------------------------
	else
		if speed > self.minspeed then self:EmitSound(self.sound_impact) end
-----------------------------------------------------------------------------------
	end
end

That's all it takes to solve what would otherwise be an unnecessarily complicated problem.

Scenario 2: Health System

A "health manager" system seems like a simple behavior that would work well. Each script just references the HealthManager component to get and modify the health value. Other scripts could look for the HealthManager component to access the same value, so you could add a script that regenerates health, for example. Seems simple enough, right? This is something I tried when I was first implementing the FPS player code.

I wanted the player camera to apply a shake effect when the player was damaged, so I called a Damage function for every component in the entity, if it is present:

local n
for n = 1, #enemy.components do
	if isfunction(enemy.components[n].Damage) then
		enemy.components[n]:Damage(self.damage, self.owner)      		
	end
end

The HealthManage:Damage function just removes health:

function HealthManager:Damage(amount, attacker)
	self.health = self.health - amount
end

In the player Damage function I set a value that applies the camera shake motion if the health is greater than zero and plays a sound of the player yelling in pain (simplified for clarity):

function FPSPlayer:Damage(amount, attacker)
	if self.entity.HealthManager then
		if self.entity.HealthManager.health > 0 then
			self:StartCameraShake()
			self.entity:EmitSound(self.sound_pain)
		end
	end
end

If the HealthManager:Damage function is called first, everything is fine. But sometimes there is a problem. If the FPSPlayer:Damage function gets called first, the camera shake effect and the pain sound will get applied even if the player dies.

One way to prevent this would be if the HealthManager is responsible for calling a different function:

function HealthManager:Damage(amount, attacker)
	self.health = self.health - amount
	if self.health > 0 then
		local n
		for n = 1, #self.entity.components do
			self.entity.components[n]:RespondToDamage(amount, attacker)
		end
	end
end

And we just rename the FPSPlayer Damage function to "RespondToDamage". Now we have set up an excessively complicated chain of function calls, and since the system requires the presence of the HealthManager component, it's not really very modular. So what was the point of doing all this that could have been done much more simply?

The actual manifestation of this problem was I had a player that wouldn't fully die! Their health went to zero but they could still move around the level. This is something I actually had a problem with, and it was fairly difficult to find the cause, so these problems do occur during real usage. I provided a simpler scenario above for improved clarity.

Here is the solution using the entity properties approach, taking advantage of Lua's dynamic typing. This is the code that applies the damage to the enemy:

if isfunction(enemy.Damage) then
	enemy:Damage(self.damage, self.owner)
end

And here is the FPSPlayer:Damage function:

function FPSPlayer:Damage(amount, attacker)
	self.health = self.health - amount
	if self.health > 0 then
		self:StartCameraShake()
		self:EmitSound(self.sound_pain)
	end
end

Again, this is just a simple example of real game mechanics. For more complex interactions, the amount of unnecessary complexity will increase greatly, requiring an ever-increasing number of workarounds to compensate for the basic problems I have described here.

Lua Entity Scripts

Now for C++, leaving this as-is doesn't really cause any problems. I can warn people of the potential pitfalls and leave it to them. However, in Lua the component approach prevents us from taking advantage of Lua's dynamic typing. The component approach requires that all properties are stored in separate sub-objects, instead of directly on the entity itself, which makes programming with this approach more tedious and less productive, even if multiple scripts are not in use. When we try to shoehorn complexity into Lua that is only necessary for statically typed languages, I feel it becomes "Lua for people who aren't really into Lua". We can try to make Lua sort of act like C++ or C#, but the result is a bad copy of that language, while giving up the simplicity and flexibility that makes Lua shine.

The newer approach I am testing, where you just have properties on the entity, feels like a dream come true to me. I feel I can program any game I want very quickly with this approach, and it makes me excited about the prospect of coding more examples for you. This is how I wanted Leadwerks 4 to work, but it's only become possible more recently. Since properties are on the entity itself, this approach does not support multiple scripts per entity, but coding with it feels fun and effortless to me. I am curious to hear how other people feel when they try using this approach.

Both of these option are currently available on Steam. "beta-e" uses the approach I am recommending, and will use properties attached to the entity. "beta-s" will use properties attached to multiple sub-objects/components. 

image.thumb.png.4101b3257cb273f7eac650c59dd5bc1a.png

My job is to make tools you love, with the features you want, and performance you can't live without.

  • Josh locked this topic
Guest
This topic is now closed to further replies.
×
×
  • Create New...