How to script a tower - Praytic/youtd2 GitHub Wiki

Before you start

Follow these steps to understand what the tower does:

  1. Find your tower on this website: https://interactive.youtd.best/towers/
  2. Read the specials and spells section.
  3. Press on the "Toggle Triggers" button to see tower's script. Not all towers have this.
  4. Press again on invidiual sections to see contents.
  5. Look at existing scripts for towers that are similar to the tower you are scripting.
  6. Translate the description of specials and the scripts in triggers section into your tower script.

API differences

Most of the functions have the same names in godot API, but with camelCase changed to snake_case. Some functions have different paramaters. See below for comparison.

Built-in tower functions

Original Godot
init tower_init
onTowerDetails on_tower_details
onCreate on_create

BuffTypes and Modifiers

Original Godot
set buff_type = BuffType.create(time, timeLevelAdd, false) var buff_type: BuffType = BuffType.new("example_buff", time, time_level_add, false)
local Modifier example_modifier = Modifier.create() var example_modifier: Modifier = Modifier.new()
call example_modifier.addModification(MOD_DMG_TO_MASS, value, value_add) example_modifier.add_modification(Modification.Type.MOD_DMG_TO_MASS, value, value_add)
call buff_type.setBuffModifier(example_modifier) buff_type.set_buff_modifier(example_modifier)
call buff_type.apply(tower, target, level) buff_type.apply(tower, target, level)
call buff_type.applyCustomTimed(tower, target, level, time) buff_type.apply_custom_timed(tower, target, level, time)
call buff_type.applyCustomPower(tower, target, level) buff_type.apply_custom_power(tower, target, level)
call buff_type.applyOnlyTimed(tower, target, time) buff_type.apply_only_timed(tower, target, time)
call buff_type.applyAdvanced(tower, target, level, power, time) buff_type.apply_advanced(tower, target, level, power, time)

Events

Original Godot
call buffType.addEventOnLevelUp(handler_function) buff_type.add_event_on_level_up(handler_object, handler_function)
call buffType.addPeriodicEvent(handler_function, period) buff_type.add_event_handler_periodic(handler_object, handler_function, period)
call buffType.addEventOnUnitComesInRange(handler_function, radius, target_type) buff_type.add_event_handler_unit_comes_in_range(handler_object, handler_function, radius, target_type)

Effects

Original Godot
Effect(id).destroy() Effect.destroy_effect(id)
Effect(id).noDeathAnimation() Effect.no_death_animation(id)

Misc

Original Godot
RMaxBJ(a, b) max(a, b)
R2I(x) int(x)
TriggerSleepAction(time) await get_tree().create_timer(time).timeout
getOwner() getOwner (get_owner() should not be used, it's reserved by godot)

Things that are not shown in original scripts

Specials

Towers can have special properties which are displayed in the "Specials" section on the youtd website. These properties are only described in text, there's no code for them in original scripts.

Here's an example of how to add a special "+30% dmg to masses (+1%/lvl)":

func load_specials():
    var modifier: Modifier = Modifier.new()
    modifier.add_modification(Modification.Type.MOD_DMG_TO_MASS, 0.3, 0.01)
    add_modifier(modifier)

Some specials do not involve modifiers. To implement them, call these functions inside load_specials():

Original Godot
Splash Attack: 600 AoE: 10% damage _set_attack_style_splash({600, 0.10})
Bounce attack: 2 targets, -10% damage per bounce _set_attack_style_bounce(2, 0.10)
This tower attacks 4 targets at once _set_target_count(4)

Adding triggers

Some towers have "triggers" with names like "On Damage", "On Kill". Before implementing the trigger function, you need to add it to the tower.

Here's an example of how you would add an "On Level up" trigger:

func load_triggers(triggers: BuffType):
    triggers.add_event_on_level_up(self, "on_level_up")

func on_level_up():
#   event handler code here

For "On Damage" and "On Attack" triggers you will also need to define chance parameters. You can see chance parameters on the website in this format:

ONDAMAGE_chance: 0.008 ONDAMAGE_chanceLevelAdd: 0.0015

Use these value like this:

func load_triggers(triggers: BuffType):
    triggers.add_event_on_damage(self, "on_damage", 0.008, 0.0015)

Note that the following functions don't need to be added as handlers in load_triggers():

  • init
  • onTowerDetails
  • onCreate

Example translation

Here's an example translation of a tower that uses all of the main features.

Original script

# "Specials" section
# +10% crit chance (+1%/lvl)

# "Header" section

globals
    //@export
    BuffType exampleBuff
endglobals

function exampleBuffPeriodic takes Buff b returns nothing
    if not b.getBuffedUnit().isImmune() then
        call b.getCaster().doSpellDamage(b.getBuffedUnit(), 100, b.getCaster().calcSpellCritNoBonus())
        call SFXOnUnit("Objects\\Spawnmodels\\Human\\HumanBlood\\HumanBloodRifleman.mdl", b.getBuffedUnit().getUnit(),"chest")
    endif
endfunction

private function init takes nothing returns nothing
    local Modifier m = Modifier.create()
    call m.addModification( MOD_SPELL_DAMAGE_RECEIVED, 0.02, 0.01 )
    
    set exampleBuff = BuffType.create( 6., 0, false)
    call exampleBuff.setBuffIcon('@@[email protected]@')
    call exampleBuff.addPeriodicEvent(EventHandler.exampleBuffPeriodic,1)
    call exampleBuff.setBuffModifier(m)
endfunction

# "On Damage" section
# ONDAMAGE_chance: 0.25
# ONDAMAGE_chanceLevelAdd: 0.01

function onDamage takes Tower tower returns nothing
    call exampleBuff.apply(tower, Event.getTarget(), 1)
endfunction

Godot script

extends Tower

var example_buff: BuffType

func load_specials():
    var modifier: Modifier = Modifier.new()
    modifier.add_modification(Modification.Type.MOD_ATK_CRIT_CHANCE, 0.10, 0.01)
    add_modifier(modifier)

func load_triggers(triggers: BuffType):
    triggers.add_event_on_damage(self, "on_damage", 1.0, 0.0)

func example_buff_periodic(event: Event):
    var b: Buff = event.get_buff()

    if !b.get_buffed_unit().is_immune():
        b.get_caster().do_spell_damage(b.get_buffed_unit(), 100, b.get_caster().calc_spell_crit_no_bonus())
        Utils.sfx_on_unit("Objects/Spawnmodels/Human/HumanBlood/HumanBloodRifleman.mdl", b.get_buffed_unit(), "chest")

func tower_init():
    var modifier: Modifier = Modifier.new()
    modifier.add_modification(Modification.Type.MOD_SPELL_DAMAGE_RECEIVED, 0.02, 0.01)

    example_buff = BuffType.new("example_buff", 6, 0, false)
    example_buff.set_buff_icon("@@[email protected]@")
    example_buff.add_periodic_event(self, "example_buff_periodic", 1)
    example_buff.set_buff_modifier(modifier)

func on_damage(event: Event):
    var tower = self

    example_buff.apply(tower, event.get_target(), 1)

Misc

getUnit()

In original API, Tower and Creep classes have getUnit() method. It returns the unit that these classes wrap. In godot API Tower and Mob(Creep) are subclasses of Unit, so getUnit() is not needed. Whenever you encounter tower.getUnit() you can replace it with just tower.

Buff power level pecularities

Sometimes in original tower scripts the power level parameter of BuffType's apply() function is used in a strange way. For example, consider this script for the Haunted Rubble tower:


globals 
    //@export
    BuffType velex_slow
endglobals

//The init function
private function init takes nothing returns nothing
    local Modifier slow=Modifier.create() 
    call slow.addModification(MOD_MOVESPEED,0,-0.001) 
    set velex_slow=BuffType.create(0,0,false) // apply custom timed  
    call velex_slow.setBuffIcon('@@[email protected]@')
    call velex_slow.setBuffModifier(slow) 
    call velex_slow.setStackingGroup("velex_slow1") 
endfunction

function onAttack takes Tower tower returns nothing
    local Unit creep = Event.getTarget() 
    local integer size = creep.getSize()
    local boolean calc

    if size == SIZE_BOSS then 
        set calc=tower.calcChance((.15+tower.getLevel()*0.0015)*2/3)
    else
        set calc=tower.calcChance(.15+tower.getLevel()*0.0015)
    endif
    if(calc==true) then
        call velex_slow.applyCustomTimed(tower,Event.getTarget(),R2I(0.15*1000),5)
    endif
endfunction

The effect of this script should be: "When this tower attacks a creep it has a 15% (10% for bosses) chance to slow it by 15% for 5 seconds. Level Bonus: +0.15% (0.1% for bosses) chance"

The important parts here are the calls to addModification() and applyCustomTimed(). addModification() receives 0 for baseValue and -0.001 for levelAdd, which doesn't make sense because tower should slow the target by 15%. applyCustomTimed() receives R2I(0.15*1000) for level which makes even less sense. You would expect to see tower's level used as an argument here.

Here are all of the reasons:

  1. Modifier was set using setBuffModifier() function which means that modifier and it's modifications will scale by buff's power level. This means that the final value of slow modification will be 0 + -0.001 * R2I(0.15*1000) = -0.15 which is the expected -15% slow based on effect description.
  2. Buff power level passed to applyCustomTimed() is used to compare buffs when a buff of same time is already active on a mob and we need to determine whether the old or new buff has priority. This is why we can't use tower's level here because in that case Haunted Rubble of level 50 would override the effect of Haunted Debris level 10 even though the slow effect of Haunted Debris is stronger. (Haunted Debris is the second tier in the family of towers that Haunted Rubble belongs to)
  3. 0.15 is multiplied by 1000 because buff power levels are integers. For the same reason, modification has to use -0.001 for the levelAdd, to remove multiplication by 1000.
  4. Modification levelAdd is negative to make the final slow value negative. Power level can't be negative because then comparison of buff power levels would be incorrect and buff's that slowed mobs less would be considered stronger.

When translating scripts with logic like this, you can translate it without changes because all of the related functions accept same parameters as in the original API.

For example, here's the script for Haunted Rubble translated to godot API:


var velex_slow: BuffType

func load_triggers(triggers_buff_type: BuffType):
    triggers_buff_type.add_event_handler(Buff.EventType.ATTACK, self, "on_attack", 1.0, 0.0)

func tower_init():
    velex_slow = BuffType.new("velex_slow", 0, 0, false)
    var slow: Modifier = Modifier.new()
    slow.add_modification(Modification.Type.MOD_MOVE_SPEED, 0, -0.001)
    velex_slow.set_buff_icon("@@[email protected]@")
    velex_slow.set_buff_modifier(slow)
    velex_slow.set_stacking_group("velex_slow1")

func on_attack(event: Event):
    var tower: Unit = self
    var mob: Unit = event.get_target()
    var size: int = mob.get_size()
    var calc: bool

    if size == Unit.MobSize.BOSS: 
        calc = tower.calc_chance((0.15 + tower.get_level() * 0.0015) * 2 / 3)
    else:
        calc = tower.calc_chance(0.15 + tower.get_level() * 0.0015)

    if calc == true:
        atrophy.apply_custom_timed(tower, mob, int(0.15 * 1000), 5.0)

How to use a single script for all tiers of a tower family

Towers in the same family are arranged into tiers and each tier differs from others by value of it's effects. For example, if towers in a family have a chance to kill a target instantly, then tier 2 will have a higher chance than tier 1. The logic of the script is the same for all towers. In original scripts, each tier had it's own script so code was duplicated with only the numerical values changing.

Here's are example scripts for tier 1 and tier 2 towers:


function onDamage takes Tower tower returns nothing
    local Unit creep = Event.getTarget() 
    local boolean calc = tower.calcChance(0.10)

    if (calc == true) then
        call tower.killInstantly(creep)
    endif
endfunction


function onDamage takes Tower tower returns nothing
    local Unit creep = Event.getTarget() 
    local boolean calc = tower.calcChance(0.20)

    if (calc == true) then
        call tower.killInstantly(creep)
    endif
endfunction

There are no changes between these two scripts except for calcChance(0.10) changing into calcChance(0.20). And this way scripts would be repeated for other tiers.

Here are the above scripts translated into godot API:

func on_damage(event: Event):
    var tower = self
    var creep: Unit = event.get_target()
    var calc: bool = tower.calc_chance(0.1)

    if calc == true:
        tower.kill_instantly(creep)
func on_damage(event: Event):
    var tower = self
    var creep: Unit = event.get_target()
    var calc: bool = tower.calc_chance(0.2)

    if calc == true:
        tower.kill_instantly(creep)

And here is the reworked script that can be used by all tiers of the tower family:


func _get_tier_stats() -> Dictionary:
    return {
        1: {instant_kill_chance = 0.10},
        2: {instant_kill_chance = 0.20},
        3: {instant_kill_chance = 0.30},
        4: {instant_kill_chance = 0.40},
        5: {instant_kill_chance = 0.50},
    }

func on_damage(event: Event):
    var tower = self
    var creep: Unit = event.get_target()
    var calc: bool = tower.calc_chance(_stats.instant_kill_chance)

    if calc == true:
        tower.kill_instantly(creep)

(Note that code for adding on_damage() event handler was omitted for clarity)

You put the value that is different for each tier into a Dictionary and place it in _get_tier_stats(). That function is called by the base class Tower and stores the stats for current tier in _stats variable. Back in the tower script, you can access stats for current tier in _stats. Each tower tier will use the same script but will get it's own version of _stats.

Checking for instance validity after sleep action

Some event handlers need to use sleep action - TriggerSleepAction() in original API. During sleep some saved variables may become invalid. For example, if in a damage event you saved the target to later do more damage to it, you need to check that the target still exists because it could've been killed and removed from the game.

In original engine this is done by saving target's UID and then comparing it to current one after sleep:

creep.getUID() != UID

If creep doesn't exist, it will return 0 which is not equal to whatever the ID was before.

This is not possible in godot engine because you can't call functions on invalid instances. Therefore we need to check validity like this:

is_instance_valid(creep)

You also need to do this for any other saved object that could've disappeared during sleep.