YouTD2 engine - Praytic/youtd2 GitHub Wiki

Introduction

Youtd2 engine is based on the original Youtd engine and the WC3 engine. The goal is to replicate the original behavior as close as possible. Even the API and function names are kept the same, wherever possible. This way, the game can reuse original tower scripts with minimal modifications, minimizing bugs.

Summary of the engine

The main class of the game is Unit, which is subclasses by Tower and Creep. Every Tower has a reference to Player class, which owns it. Every Player belongs to a Team.

There is also Item class which is used for items which can be equipped by towers.

Both Tower and Item classes represent the core common functionality. Functionality which is specific to a particular tower or item is implemented by plugin classes called TowerBehavior and ItemBehavior. These classes are plugged into Tower or Item class at runtime to create a complete instance of Tower or Item.

For casting spells, the engine provides a SpellType class where you describe what ability should do. SpellType can then be used to create instances of spells called SpellDummy. Examples: chain lightning, forked lightning, blizzard.

The Buff engine uses the BuffType class. BuffType stores the icon, buff effects, modifications, events. You can then call an apply() function of BuffType to create a Buff instance on a unit.

Projectile class is used for projectiles which are moving sprites that can deal damage on impact and many other things.

Below is an in-depth description of the engine parts.

Basic structures

Unit

Where do you get units from?

First, if you are writing code for a TowerBehavior class, then you will always have access to a tower variable. This is the tower which owns the TowerBehavior. For example, if you have a callback for KILL event and you want to grant mana to tower when it kills a creep, you would do this:

func on_kill(event: Event):
    tower.add_mana(5)

For targeted events, you can call Event.get_target():

func on_XXXX(event: Event):
    var target: Unit = event.get_target()

It returns the target of this event. For example, for on_kill, on_attack, on_damage events, it gets you the killed/attacked/damaged unit.

Note that not all events have targets. For example, the LEVELUP event doesn't have a target.

You can also check if a unit is a Tower or a Creep. For some events, you can just cast the Unit to Creep or Tower because it is clear what it is. Example: on_attack event will always have a Creep target because towers don't attack each other.

If it is unclear if the unit is a Tower or a Creep, you can use Godot's builtin is function:

var unit: Unit = event.get_target()
if unit is Creep:
   print("This unit is a creep")
if unit is Tower:
   print("This unit is a tower")

After having learned how to acquire a unit and figure out it's type, let's learn what you can do with it. Unit class has a huge amount of functions you can use. Here are a few of them. For complete list check the source code.

class Unit:
// Returns the owning player of this unit
func get_player() -> Player:

// Calculates random spell crit ratio for this tower. Returns 1.0 if crit roll failed and some value above 1.0 if crit happened.
func calc_spell_crit_no_bonus() -> float:

// Calculates a chance for this unit.
// Use this function whenever you want something to happen to this unit on a percent base.
// Example: use tower.calc_chance(0.5) to get a 50% chance.
// Will return true on 50% of calls
// Note that input percentage will be modified by unit's "trigger chances" stat
func calc_chance(chance: float) -> bool:

// Returns a buff of BuffType if the unit has it.
// If the unit has no buff, returns null.
func get_buff_of_type(buff_type: BuffType) -> Buff:

Player and Team classes

You can get Players by using Unit.get_player(). You can get Teams by using Player.get_team().

Player and Team classes offer functions to display floating text, change gold/income and so on.

Here are some functions for Player (again, complete list can be found in source code):

class Player:

// Creates a floating text which is only visible for this player.
// Floating text appears above the given unit.
func display_floating_text(text: String, unit: Unit, color: Color):

// Returns the number of towers this player has
func get_num_towers() -> int:

// Modifies the interest rate of this player by a percentage.
// 0.01 = 1%
func modify_interest_rate(value: float):

// Gives gold to this player. You can specify a unit for this, if you do so then
// there will be a visual effect at the unit's position (if show_effect is true) and a
// golden floating text that shows the value of gold gained (if show_text is true)
// You can use a negative value to subtract gold (then the floating text will be red instead of gold)
func give_gold(value: int, unit: Unit, show_effect: bool, show_text: bool):

And here are some methods for Team class:

class Team:

// Returns the current wave level of this team
func get_level() -> int:

// Returns the percentage of lives this has has.
// 0.4 means 40% lives left.
func get_lives_percent() -> float:

Now you have basic knowledge of what can be done in the engine, how to acquire Units, their owning Players and their Teams.

The Modification System

Let's start with the easiest things you can do to alter a Tower / Creep. Modifying its values like speed, damage, armor and so on.

The YouTD API allows you to alter many advanced things like critical strike chance, item drop chance.

There are many modification types: MOD_ARMOR, MOD_BASE_DAMAGE, MOD_MOVESPEED. You can read details about each modification type in modification.gd source file.

The most simple method to alter a Unit's stats is this one:

class Unit:

func modify_property(modification_id: int, value: float):

Using modify_property() you can directly modify a single property of a unit. Note that this modification is permanent. This is not the only way to alter unit's properties. For example, buffs can modify properties for as long as the buff is applied on a unit. When the buff expires, modifications are automatically reset. This is accomplished with the Modifier class.

Modifier class

A modifier is basically a set of modifications, which are also level dependent. That means they can get upgraded automatically if the tower/buff gains levels. This way you could make a modifier that gets stronger the higher the towers level is.

You create a modifier using like this:

var modifier: Modifier = Modifier.new()

And add modifications to it with add_modification():

func add_modification(modification_type: int, value_base: float, level_add: float):

This method adds a modification to this modifier. modification_type is one of the above mentioned constants which specifies which property to modify.

value_base is the value that is modified for a level 0 tower/buff. level_add is the value that is added for every level. You can add as many modifications as you wish to a modifier.

Example:

var modifier: Modifier = Modifier.new()
modifier.add_modification(Modification.Type.MOD_ATK_CRIT_CHANCE, 0.1, 0.01)

This would create a modifier that modifies a tower's critical strike chance by 10% (0.1) on level 0 and +1% per level. So for a level 15 tower, this would give 25% more critical strike chance.

But this modifier is not on a tower yet. It can be added to buffs (but that will be covered in the chapter on buffs). It can also be added to a Unit directly using this method:

func add_modifier(modifier: Modifier):

This adds a modifier to the unit's modifier list. Note that the modifier's strength is automatically adjusted with the unit's level. If the unit gains a level also this modification's strength's will be adjusted.

You can also later remove the modifier:

func remove_modifier(modifier: Modifier):

Note that the functions add_modifier() and remove_modifier() should only be used in special cases. Most of the time you just add a modifier to a buff. Then the modifier will be on the unit as long as the unit has the buff. You don't have to care about adding and removing the modifier. This is covered in the chapter on buffs.

Buff system

The buff system allows you to create triggered buffs that modify the creep/tower values and can react to events like: the buffed unit dies, the buff expires and so on.

Creating a BuffType

Each buff belongs to a BuffType that represents its values. To create a new kind of buff, you create a BuffType in your init function, save it in a variable and then you can apply it to units.

The create method of BuffType looks like this:

class BuffType:

func _init(variable_name: String, time_base: float, time_level_add: float, friendly: bool, owner_node: Node):

The arguments have the following meaning:

  • variable_name: This value should be equal to the name of the variable for BuffType. It's used to create a unique name for BuffType.
  • time_base: The time in seconds a buff last on a unit, if its power level is 0. If this value is set to -1, then the buff is permanent.
  • time_level_add: The time in seconds that is added to the base time for each power level a buff has. So for example if this is 10, then a power level 3 buff will last 30 seconds longer than a level 0 buff. If the buff is permanent, you can just set this to zero (or any other value).
  • friendly: If this buff is a positive (true) or negative (false) buff.

The "power level" of a buff is a measure for how strong it is. We will talk about that later. If you set time_base to -1.0 then the buff will last infinitely on its target. This can be used to create permanent buffs or buffs that are removed when some conditions are true, like when the unit attacks or something like that.

After invoking this method, we have a BuffType with a specific duration. However the buff doesn't do anything yet.

Adding modifications to a buff

The easiest way to let our buff do something is setting its modifier. A modifier set this way modifies the unit values of the buff as long as the unit has the buff on it. As soon as the buff is removed, the modifications will vanish from the unit.

To set the modifier of a buff type use this function:

func set_buff_modifier(modifier: Modifier):

This will set the buff type's modifier. Whenever a buff of this type is applied to a unit, all modifications stated in the modifier will be applied.

Example how to create a buff type and add some modifications to it:

func tower_init():
    var my_bt: BuffType.new("my_bt", 10.0, 1.0, true, self)
    var mod: Modifier = Modifier.new()
    mod.add_modification(Modification.Type.MOD_DMG_TO_ORC, 0.20, 0.05)
    mod.add_modification(Modification.Type.MOD_MANA, 100, 10)
    my_bt.set_buff_modifier(mod)

This creates a buff type that lasts 10 seconds (+1s per power level) on a unit. It is a positive buff. It adds 20% damage against orc creeps on power level 0 and additional 5% for each power level. It also adds 100 to the tower's mana and +10 mana for each power level.

Okay, now you have created a buff type. So lets use it!

Applying a buff

You can apply a buff onto a unit by calling this function:

func apply(caster: Unit, target: Unit, level: int) -> Buff:

It takes two Units, the unit that is the caster of the buff and the unit that is the target of the buff. As a third parameter it takes the power level the buff should have.

Just call this function whenever you want to place a buff. The engine does the rest (i.e. checking if the unit already has the buff, doing the modifications, removing it when its time is up and so on).

As already stated, the buff's power level is used for the following things:

  • It modifies the duration of the buff
  • It modifies the strength of its modifier
  • It is used for upgrading purposes. A power level 5 buff will upgrade a power level 3 buff, instead of replacing it.

Lets take the buff type we have created in the example above. Lets make that our tower has a 10% chance on each attack, that the tower will buff itself with the buff. As buff power level, the tower will use its own level.

func load_triggers(triggers: BuffType):
    triggers.add_event_on_attack(on_attack)

func on_attack(_event: Event):
    if !tower.calc_chance(0.10):
        return
    
    my_bt.apply(tower, tower, tower.get_level())

Adding event reactions to BuffType

When you create a buff type, I showed you how to add modifications to it. However, modifications alone are not too flexible. Event reactions are much more powerful. A buff (or the unit it is cast on) produces a large number of events, that we can catch by setting event reaction handlers.

We can react to the following events:

  • On Create: This event is fired whenever the buff is created (i.e. applied to a unit that doesn't have it yet).
  • On Refresh: This event is fired whenever the buff is refreshed (i.e. applied to a unit that already has it with the same level).
  • On Upgrade: This event is fired whenever the buff is upgraded (i.e. applied to a unit that already has it with a lower level).
  • On Cleanup: This event is fired when the buff is somehow removed or the unit it is on dies. Do your garbage collection tasks here.
  • On Purge: This event is fired when the buff is forcibly removed by a purge like ability.
  • On Expire: This event is fired when the buff is removed due to having used up its duration.
  • Periodic: This event is fired periodically (you can specify the period).
  • On Spell Cast: This event is fired whenever the buffed unit casts a spell
  • On Spell Target: This event is fired whenever the buffed unit is targeted by a spell
  • On Death: This event is fired when the buffed unit dies
  • On Kill: This event is fired whenever the buffed unit kills a unit
  • On Attack: This event is fired whenever the buffed unit attacks
  • On Attacked: This event is fired whenever the buffed unit is attacked
  • On Damage: This event is fired whenever the buffed unit deals attack damage to a creep (spell damage and attack damage from abilities doesn't count)
  • On Damaged: This event is fired whenever the buffed unit is damaged (all damage sources, attacks and spells)
  • On Unit Comes In Range: This event is fired whenever a unit (which can be filtered like in the tower event) comes in a specific range to the buffed unit.

So as you can see, you can react to almost everything. This gives you limitless possibilities.

Note that not all events make sense for all kinds of units. For example, a tower will never be damaged or attacked!

Now I will tell you how this works in code. To add an event reaction handler, first write the reaction handler function. It should a signature like this:

func on_EVENT(event: Event):

After you have written such a function you can call the appropriate add_event() function of the BuffType to set this function as an event callback. You can find a list of all add_event() functions in buff_type.gd.

In the event callback itself, you can use many methods of the buff. For example get_buffed_unit() will get the unit container, on which the buff is cast.

An example will make this more clear and will show how mighty this concept is. Let's create a buff, that whenever the buffed tower attacks, there is a 10% chance that the tower will deal 100 bonus spell damage to the attacked creep.

First the reaction function:

func on_attack(event: Event):
    if tower.calc_chance(0.10):
        return

    var buff: Buff = event.get_buff()
    var buffed_unit: Unit = buff.get_buffed_unit()
    var target: Unit = event.get_target()
    buffed_unit.do_spell_damage(target,, 100, buffed_unit.calc_spell_crit_no_bonus())

Again we use Event.getTarget() to refer to the attacked unit.

This is the function. Now just tell the bufftype to call this function in 10% of all attacks.

We do this in the init function after creating our buff type:

func tower_init():
    my_bt = BuffType.new("my_bt", 10.0, 1.0, true, self)
    my_bt.add_event_on_attack(on_attack)

The whole code would be:

class MyTower extends TowerBehavior

var my_bt: BuffType

func tower_init():
    my_bt = BuffType.new("my_bt", 10.0, 1.0, true, self)
    my_bt.add_event_on_attack(on_attack)

func on_attack(event: Event):
    if tower.calc_chance(0.10):
        return

    var buff: Buff = event.get_buff()
    var buffed_unit: Unit = buff.get_buffed_unit()
    var target: Unit = event.get_target()
    buffed_unit.do_spell_damage(target,, 100, buffed_unit.calc_spell_crit_no_bonus())

As you saw in this example we used get_buffed_unit() on the buff to get the unit it buffs, there are some more useful functions for buffs. For a full list, check source code.

class Buff:

// Gets the power of this buff (for normal buffs, this is equal to the level)
func get_power() -> int:

// GEts the Unit that casted this buff
func get_caster() -> Unit:

// Returns how many seconds until this buff expires
func get_remaining_duration() -> float:

// Removes this buff from the unit
func remove_buff():

Buff icons and descriptions

Each buff needs an icon, name and description. They should be defined in init function:

func tower_init():
    slow_bt = BuffType.new("slow_bt", 0.10, 0.0, false, self)
    slow_bt.set_buff_icon("res://resources/icons/generic_icons/foot_trip.tres")
    slow_bt.set_buff_tooltip("Icy Touch\nReduces movement speed.")

Projectiles

Projectiles are objects that can move in many ways (home in on a target, fly straight ahead, fly ballistically impacting or bouncing from the ground and some more). By default, they don't do anything but moving. However, just like with buffs, you can react to many events, thus giving them influence on the gameplay.

Major types of projectiles

Before I tell you how to create projectiles, I want to talk about the two major types of projectiles the YouTD engine supports.

The first type of projectiles are normally moving projectiles. These have a direction and a speed and just fly into that direction. They can be altered in many ways by giving them acceleration, homing, gravity and more.

The second type are interpolated projectiles. Interpolated projectiles follow a fixed trajectory with a fixed interpolation curve. That means you cannot set things as freely as you can for moving projectiles.

I will call the first one "normally moving projectile" or just "moving projectile" and the second one "interpolated projectile".

Creating a ProjectileType

ProjectileType is a class which is created once to define a projectile and then can be used to create projectile instances.

There are three functions for creating ProjectileType's:

// Normal moving projectile
static func create(model: String, lifetime: float, speed: float, parent: Node) -> ProjectileType:

// Interpolated projectile
static func create_interpolate(model: String, speed: float, parent: Node) -> ProjectileType:

// Normal moving projectile, restricted to a range
static func create_ranged(model: String, the_range: float, speed: float, parent: Node) -> ProjectileType:

Arguments:

  • model: The path to a scene which should be used as a visual for the projectile. For example: res://src/projectiles/projectile_visuals/default_projectile.tscn.
  • lifetime: The time in seconds until this projectile expires.
  • The initial speed of the projectile.

Interpolated projectiles have no lifetime, they will automatically expire when they reach the destination point.

Launching a projectile

After you have created a ProjectileType, you can launch projectiles from it. You do this by invoking one of the constructors of the struct Projectile. There are different constructors for normally moving and interpolated projectiles. Don't let the number of existing constructors scare you! There are many, but only differ in the start and target of that projectile. Projectiles can be launched from Units or points (x,y,z), they can target a point, a Unit or just face a specific angle. There are constructors for each combination of start types and targets.

Note that this API is a bit loose, so you need to manually make sure to use projectile constructors which match with the type of the ProjectileType. If a ProjectileType is interpolated, then you must use one of the corresponding create() functions for interpolated projectiles. If you mix projectile types, you will get undefined behavior.

This chapter will just tell the stuff that every constructor has. Everyone of them takes these values:

  • pt: The ProjectileType to which this projectile blongs
  • caster: The unit that casted the projectile
  • damage_ratio and crit_ratio: Read below for details

Damage ratio and Crit ratio

Damage ratio and crit ratio modify the damage dealt by the projectile.

Damage ratio will multiply the damage dealt by projectile. If it's equal to 1.5, then total damage will be 150% of given value.

Crit ratio is a also a multiplier which is supposed to be obtained like this:

var crit_ratio: float = tower.calc_spell_crit_no_bonus()

Note that these variables are only relevant if you use one of the DummyUnit damaging functions (DummyUnit is base class of Projectile).

// Will be affected by damage ratio and crit ratio
projectile.do_spell_damage(...)

// Won't be affected by damage ratio and crit ratio
caster.do_spell_damage(...)

If you deal damage via the tower, then you can ignore these parameters. Just set both damage ratio and crit ratio to 1.0.

Example of launching projectiles

Let's create a tower that shoots a projectile into the direction of the attacked creep every time it attacks.

First, create ProjectileType:

var my_pt: ProjectileType

func tower_init():
    my_pt = ProjectileType.create("res://src/projectiles/projectile_visuals/default_projectile.tscn", 4.0, 500, self)

The projectile uses default visual model, flies with 500 seconds for 4.0 seconds.

Now, launch the projectile:

static func create_from_point_to_unit(type: ProjectileType, caster: Unit, damage_ratio: float, crit_ratio: float, from_pos: Vector3, target_unit: Unit, targeted: bool, ignore_target_z: bool, expire_when_reached: bool) -> Projectile:

func on_attack(event: Event):
    var target: Unit = event.get_target()
    Projectile.create_from_unit_to_unit(my_pt, tower, 1.0, 1.0, tower, target, true, false, false)
    my_pt = ProjectileType.create("res://src/projectiles/projectile_visuals/default_projectile.tscn", 4.0, 500, self)

The projectile is casted by the tower, so we insert tower as caster. We give our projectile 100% damage ratio and crit ratio. However, if the projectile deals no damage, we can also put an arbitrary number here. The projectile should fly from our tower to the attacked creep.

We set homing to "true" to make the projectile follow the target. The last two values are set to false.

This example created a simple projectile. However, that projectile doesn't do anything yet. So read on to give your projectile more possibilities.

Basic things about projectiles

This chapter will tell you which things can be done with EACH type of projectile. The following chapter will then narrate some details that can be done with only some specific types of projectiles.

Spells

Spells have pre-built visual and damaging effects. For example, the Blizzard spell deals damage in a circular area over time, and shows a blizzard visual.

Note that in original youtd, spells were intended to be the main way that towers deal spell damage, but it turned out to be a niche functionality. Only a minority of tower scripts use spells - 16 out of ~300 towers. The majority of tower abilities do not use SpellTypes at all. I would recommend to not use spells unless you really feel like you need to. Almost all effects can be implemented without them.

Casting a spell requires two steps:

  1. Creating SpellType during initialization.
  2. Casting the spell.

Okay, that was very abstract, now let me get a bit more into detail:

Creating SpellType

This should be done during tower initialization, so use the tower_init() function, create SpellType inside it and save it in a variable:

var blizzard_st: SpellType

func tower_init():
    ...
    blizzard_st = SpellType.new(SpellType.Name.BLIZZARD, 9.00, self)
    blizzard_st.set_damage_event(blizzard_st_on_damage)
    blizzard_st.data.blizzard.damage = _stats.blizzard_damage
    blizzard_st.data.blizzard.radius = _stats.blizzard_radius
    blizzard_st.data.blizzard.wave_count = _stats.blizzard_wave_count

Signature for SpellType.new():

func _init(spell_name: SpellType.Name, lifetime: float, parent: Node):

Note that each spell type has unique parameters which need to be assigned. For blizzard, it's the amount of damage, the radius of blizzard and the wave count.

Casting spells

Okay, now we have initialized our SpellType and can cast it.

Here's a list of different casting functions:

func point_cast_from_unit_on_point(caster: Unit, origin_unit: Unit, target_pos: Vector2, damage_ratio: float, crit_ratio: float):

func point_cast_from_caster_on_point(caster: Unit, target_pos: Vector2, damage_ratio: float, crit_ratio: float):

func target_cast_from_caster(caster: Unit, target: Unit, damage_ratio: float, crit_ratio: float):

func target_cast_from_point(caster: Unit, target: Unit, origin_pos_2d: Vector2, damage_ratio: float, crit_ratio: float):

func point_cast_from_target_on_target(caster: Unit, target: Unit, damage_ratio: float, crit_ratio: float):