Team Fortress 2/Scripting/VScript Examples: Difference between revisions
|  (Remove link inside code black as wiki can't parse it) |  (→Giving a cosmetic:  Fixed broken link for "Getting the userid from a player handle") | ||
| (170 intermediate revisions by 24 users not shown) | |||
| Line 1: | Line 1: | ||
| This page contains examples of [[Vscript|vscripts]] for  | {{LanguageBar}} | ||
| {{TF2 topicon|docs}} | |||
| {{tf2}} This page contains examples of [[Vscript|vscripts]] specifically for {{tf2|1}}. | |||
| Generic examples that work on all VScript games can be found on the [[Source SDK Base 2013/Scripting/VScript Examples|SDK 2013 page]]. | |||
| == Iterating Through Player's Cosmetics == | |||
| Cosmetics are called "wearables" internally. | |||
| {{note|This can also be used for iterating over weapons, not just cosmetics}} | |||
| { | |||
| } | |||
| local  | <syntaxhighlight lang=js> | ||
| for (local wearable = player.FirstMoveChild(); wearable != null; wearable = wearable.NextMovePeer()) | |||
| { | { | ||
| 	if (wearable.GetClassname() != "tf_wearable") | |||
| 		continue | |||
| 	printl(wearable) | |||
| } | |||
| } | |||
| </syntaxhighlight> | |||
| </ | |||
| == Swapping the <tt>!activator</tt>'s Team == | |||
| Whenever a script's code gets executed by inputs ( | Whenever a script's code gets executed by inputs (e.g.,<code>RunScriptCode</code>/<code>CallScriptFunction</code>on an entity with an attached [[Entity Scripts|entity script]]), the <code>activator</code> and <code>caller</code> variables will be set to the handles of the input's !activator and !caller respectively, allowing you to access them in code. | ||
| We can use that to easily do something to players that walk in a specific trigger, for example. | We can use that to easily do something to players that walk in a specific trigger, for example. | ||
| Line 46: | Line 32: | ||
| To change the !activator's team, we should use the <code>ForceChangeTeam(Int : Team index, Bool : Kill player if game is in Highlander mode + remove their dominations)</code> method on them via <code>activator.ForceChangeTeam(...)</code>. | To change the !activator's team, we should use the <code>ForceChangeTeam(Int : Team index, Bool : Kill player if game is in Highlander mode + remove their dominations)</code> method on them via <code>activator.ForceChangeTeam(...)</code>. | ||
| To change their cosmetic items'  | To change their cosmetic items' color, we need to iterate over every ''tf_wearable'' entity, and then change its team via <code>SetTeam(Int : Team index)</code> if they are worn by the !activator. | ||
| * To iterate, we need to specify a null variable (e.g. <code>local cosmetic = null</code>), and then pass it into the following while loop: | * To iterate, we need to specify a null variable (e.g. <code>local cosmetic = null</code>), and then pass it into the following while loop: | ||
| < | <syntaxhighlight lang=js> | ||
| local cosmetic = null // Assign the "cosmetic" variable to null, will be assigned new values when going over cosmetic items | local cosmetic = null // Assign the "cosmetic" variable to null, will be assigned new values when going over cosmetic items | ||
| while (cosmetic = Entities.FindByClassname(cosmetic, "tf_wearable")) | while (cosmetic = Entities.FindByClassname(cosmetic, "tf_wearable")) | ||
| { | { | ||
| 	// Do things to individual tf_wearables entities here, the "cosmetic" variable is set to the current tf_wearable entity we are iterating through | |||
| }</ | }</syntaxhighlight> | ||
| * To check if the cosmetic item belongs to the !activator, we can simply compare the value returned by the <code>GetOwner()</code> method when we use it on the tf_wearable (e.g. <code>cosmetic.GetOwner()</code>) against the !activator. Example code: | * To check if the cosmetic item belongs to the !activator, we can simply compare the value returned by the <code>GetOwner()</code> method when we use it on the tf_wearable (e.g. <code>cosmetic.GetOwner()</code>) against the !activator. Example code: | ||
| < | <syntaxhighlight lang=js> | ||
| if (cosmetic.GetOwner() == activator) | if (cosmetic.GetOwner() == activator) | ||
| { | { | ||
| 	// Do things if the cosmetic's wearer is the !activator | |||
| } | } | ||
| </ | </syntaxhighlight> | ||
| * To change the cosmetic's team, we should use the <code>SetTeam(Int : Team index)</code> method on the cosmetic item entity. | * To change the cosmetic's team, we should use the <code>SetTeam(Int : Team index)</code> method on the cosmetic item entity. | ||
| Full code with comments: | Full code with comments: | ||
| <syntaxhighlight lang=js> | |||
| const TEAM_UNASSIGNED = 0 | |||
| const TEAM_SPECTATOR = 1 | |||
| const TF_TEAM_RED = 2 | |||
| const TF_TEAM_BLUE = 3 | |||
| function ActivatorSwapTeam() // Call this in map via RunScriptCode > ActivatorSwapTeam() or CallScriptFunction > ActivatorSwapTeam on a logic_script that has an Entity Script with this function | function ActivatorSwapTeam() // Call this in map via RunScriptCode > ActivatorSwapTeam() or CallScriptFunction > ActivatorSwapTeam on a logic_script that has an Entity Script with this function | ||
| { | { | ||
| 	// The following snippet checks if an !activator has been specified and if they are a player | |||
| 	// If either question's answer is no, then don't execute the rest of the function | |||
| 	if (activator == null || activator.IsPlayer() == false) | |||
| 		return | |||
| 	// The following snippet compares the !activator's team number (Ranging from 0 to 3) to the ones used by Unassigned (0) and Spectator (1) | |||
| 	// If they match with either Unassigned or Spectator, we don't execute the rest of the function | |||
| 	// Used to ignore any potentional spectator !activators | |||
| 	local current_team = activator.GetTeam() | |||
| 	if (current_team == TEAM_UNASSIGNED || current_team == TEAM_SPECTATOR) | |||
| 		return | |||
| 	// The following snippet specifies a local new_team variable, and then we set it to a team number based off the !activator's current team number | |||
| 	local new_team | |||
| 	if (current_team == TF_TEAM_RED) // Checks if the !activator's team number is 2 (Red), and sets the new_team variable to 3 (Blu) | |||
| 		new_team = TF_TEAM_BLUE | |||
| 	else // If the !activator's team number is not 2 (Red), sets the new_team variable to 2 (Red) instead | |||
| 		new_team = TF_TEAM_RED | |||
| 	// The following snippet calls the ForceChangeTeam method on the !activator | |||
| 	// First parameter: Team number to switch to | |||
| 	// Second parameter: If false, the game will reset the player's dominations and nemesises, and kill them if mp_highlander is on | |||
| 	activator.ForceChangeTeam(new_team, true) | |||
| 	local cosmetic = null // Assign the "cosmetic" variable to null, will be assigned new values when going over cosmetic items | |||
| 	// The following snippet will go over every cosmetic item currently present, and will change its colours to the appropriate team if they are the !activator's | |||
| 	while (cosmetic = Entities.FindByClassname(cosmetic, "tf_wearable")) // Goes over every cosmetic item, executing code below | |||
| 	{ | |||
| 		if (cosmetic.GetOwner() == activator) // Checks if the currently iterated cosmetic item's wearer is the !activator | |||
| 		{ | |||
| 			cosmetic.SetTeam(new_team) // Sets the team of the cosmetic item to the new team number that we stored in new_team | |||
| 		} | |||
| 	} | |||
| } | } | ||
| </ | </syntaxhighlight> | ||
| == | == Adding attributes to player on spawn == | ||
| Intuitively, applying attributes to the player on the <code>player_spawn</code> event should work. Unfortunately this doesn't work in practice, as the game clears all custom attributes immediately after this event is fired. As a workaround, the attributes can be postponed to be added at the end of the frame, as shown below. The team check is requied because <code>player_spawn</code> runs once when a player is put on the unassigned team. | |||
| {{note|This assumes event callbacks are setup already, like in the example above.}} | |||
| <syntaxhighlight lang=js> | |||
| const TEAM_UNASSIGNED = 0 | |||
| ::PostPlayerSpawn <- function() | |||
| { | { | ||
| //  | 	// "self" is the player entity here | ||
| 	self.AddCustomAttribute("no jump", 1, -1) | |||
| } | |||
| function OnGameEvent_player_spawn(params) | |||
| / | { | ||
| 	local player = GetPlayerFromUserID(params.userid) | |||
| 	if (params.team != TEAM_UNASSIGNED) | |||
| 	{ | |||
| 		EntFireByHandle(player, "CallScriptFunction", "PostPlayerSpawn", 0, null, null) | |||
| 	} | |||
| } | |||
| </syntaxhighlight> | |||
| == Setting up a Boss Health Bar == | |||
| local  | The boss bar that appears while any bosses are active (such as MONOCULUS!) is handled by the <code>monster_resource</code> entity, which conveniently exists in the map normally. | ||
| You can do <code>[[Team Fortress 2/Scripting/Script Functions#CEntities|Entities]].FindByClassname(null, "monster_resource")</code> to get the <code>monster_resource</code> entity most of the time, but it'd be convenient to store it into a variable instead. | |||
| In order to modify the health bar's percentage, it'd be useful to first know about [[Team Fortress 2/Scripting/Script Functions#CNetPropManager|NetProps]]: | |||
| * NetProps are network properties of an entity, which are server-side only. | |||
| * They can be accessed and changed by the [[Team Fortress 2/Scripting/Script Functions#CNetPropManager|NetProps]] class methods. | |||
| The <code>monster_resource</code> entity has a ''m_iBossHealthPercentageByte'' NetProp, which determines the percentage state of the health bar based on a byte value - its value must be between 0 and 255, where 0 is 0%, and 255 is 100% | |||
| The following code will add a health bar with 25% health after executed. | |||
| <syntaxhighlight lang=js> | |||
| local health_bar = Entities.FindByClassname(null, "monster_resource") // Get the health bar entity. | |||
| if (health_bar) // Check if the health bar entity exists, just in case to prevent errors | |||
| { | |||
| 	// The following line will update the health bar's percentage to 25% by changing its NetProp. | |||
| 	// Do note that because it is a byte (0 - 255), we need to multiply our percentage by 255. | |||
| 	NetProps.SetPropInt(health_bar, "m_iBossHealthPercentageByte", 0.25 * 255) | |||
| } | |||
| </syntaxhighlight> | |||
| == Giving weapons, cosmetics and taunts == | |||
| All weapons, cosmetics, taunts, economy items etc are stored within <code>items_game.txt</code>. This file is found within the game's directory <code>/tf/scripts/items/</code>. | |||
| === Giving a weapon === | |||
| See [https://wiki.alliedmods.net/Team_fortress_2_item_definition_indexes this page] to find corresponding classname and item_ids for each weapon. | |||
| {{warning|This will cause a client crash when giving an Engineer toolbox ([[tf_weapon_builder]] with item_id 28). To fix this, you must set the <code>m_iObjectType</code> and <code>m_iObjectMode</code> netprops to 0 before equipping. You also need to set the <code>m_aBuildableObjectTypes</code> netprop array to allow objects to be built.}} | |||
| <syntaxhighlight lang=js> | |||
| const MAX_WEAPONS = 8 | |||
| ::GivePlayerWeapon <- function(player, classname, item_id) | |||
| { | { | ||
| 	local weapon = Entities.CreateByClassname(classname) | |||
| 	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", item_id) | |||
| 	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	NetProps.SetPropBool(weapon, "m_bValidatedAttachedEntity", true) | |||
| 	weapon.SetTeam(player.GetTeam()) | |||
| 	//  | 	weapon.DispatchSpawn() | ||
| 	// remove existing weapon in same slot | |||
| 	for (local i = 0; i < MAX_WEAPONS; i++) | |||
| 	{ | |||
| 		local held_weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i) | |||
| 		if (held_weapon == null) | |||
| 			continue | |||
| 		if (held_weapon.GetSlot() != weapon.GetSlot()) | |||
| 			continue | |||
| 		held_weapon.Destroy() | |||
| 		NetProps.SetPropEntityArray(player, "m_hMyWeapons", null, i) | |||
| 		break | |||
| 	} | |||
| 	player.Weapon_Equip(weapon) | |||
| 	player.Weapon_Switch(weapon) | |||
| 	return weapon | |||
| } | |||
| GivePlayerWeapon(GetListenServerHost(), "tf_weapon_smg", 16) | |||
| </syntaxhighlight> | |||
| === Giving a cosmetic === | |||
| Spawns a [[tf_wearable]] entity made by the ''B.A.S.E. Jumper'' item. This ''tf_wearable'' allows for any model to appear on the player's ragdoll upon death. (Player ragdolls are client-side and cannot be accessed by VScript, so there is no other way to parent anything to the ragdoll unless you use this kind of tf_wearable) | |||
| Optionally, this method can be used to update the player's bodygroups, which cannot be altered with <code>SetBodygroup()</code>. A fix is to give an econ item that removes a bodygroup, set the model to "models/empty.mdl", then send the Game Event <code>post_inventory_application</code> to the player to refresh the bodygroups of the econ items. | |||
| {{warning|These spawned items permanently stay on the player entity, so they must be manually removed. A solution is putting the item's handles into the player's scope, then when the player respawns, delete the items using a loop. (Example below)}} | |||
| {{note|You must use the function <code>GetPlayerUserID()</code> from [[Source_2013_MP/Scripting/VScript_Examples#Getting_the_userid_from_a_player_handle|an example here.]]}} | |||
| <syntaxhighlight lang=js> | |||
| ::GivePlayerCosmetic <- function(player, item_id, model_path = null) | |||
| { | |||
| 	local weapon = Entities.CreateByClassname("tf_weapon_parachute") | |||
| 	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 1101) | |||
| 	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	weapon.SetTeam(player.GetTeam()) | |||
| 	weapon.DispatchSpawn() | |||
| 	player.Weapon_Equip(weapon) | |||
| 	local wearable = NetProps.GetPropEntity(weapon, "m_hExtraWearable") | |||
| 	weapon.Kill() | |||
| 	NetProps.SetPropInt(wearable, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", item_id) | |||
| 	NetProps.SetPropBool(wearable, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	NetProps.SetPropBool(wearable, "m_bValidatedAttachedEntity", true) | |||
| 	wearable.DispatchSpawn() | |||
| 	// (optional) Set the model to something new. (Obeys econ's ragdoll physics when ragdolling as well) | |||
| 	if (model_path) | |||
| 		wearable.SetModelSimple(model_path) | |||
| 	// (optional) recalculates bodygroups on the player | |||
| 	SendGlobalGameEvent("post_inventory_application", { userid = GetPlayerUserID(player) }) | |||
| 	// (optional) if one wants to delete the item entity, collect them within the player's scope, then send Kill() to the entities within the scope. | |||
| 	player.ValidateScriptScope() | |||
| 	local player_scope = player.GetScriptScope() | |||
| 	if (!("wearables" in player_scope)) | |||
| 		player_scope.wearables <- [] | |||
| 	player_scope.wearables.append(wearable) | |||
| 	return wearable | |||
| } | |||
| </syntaxhighlight> | |||
| {{tip|For vision restricted items (halloween hats, romevision), use the ''model_path'' argument and use a random dummy item id instead.  For example: | |||
| <syntaxhighlight lang=js> | |||
| GivePlayerCosmetic(GetListenServerHost(), 9911, "models/workshop/player/items/soldier/tw_soldierbot_helmet/tw_soldierbot_helmet.mdl") | |||
| </syntaxhighlight>  | |||
| to equip the romevision soldier bot helmet. | |||
| }} | |||
| === Giving a taunt === | |||
| There is no known way to give taunts directly into the inventory, but all taunts can be forced using a workaround as shown below. | |||
| For a list of taunts, search their ID in <code>items_games.txt</code>. The [https://wiki.alliedmods.net/Team_fortress_2_item_definition_indexes#Taunt_Items Alliedmodders page] also has an incomplete list. | |||
| Example usage (conga): <code>ForceTaunt(GetListenServerHost(), 1118)</code> | |||
| <syntaxhighlight lang=js> | |||
| const TF_COND_TAUNTING = 7 | |||
| function ForceTaunt(player, taunt_id) | |||
| { | |||
| 	local weapon = Entities.CreateByClassname("tf_weapon_bat") | |||
| 	local active_weapon = player.GetActiveWeapon() | |||
| 	player.StopTaunt(true) // both are needed to fully clear the taunt | |||
| 	player.RemoveCond(TF_COND_TAUNTING) | |||
| 	weapon.DispatchSpawn() | |||
| 	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", taunt_id) | |||
| 	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	NetProps.SetPropBool(weapon, "m_bForcePurgeFixedupStrings", true) | |||
| 	NetProps.SetPropEntity(player, "m_hActiveWeapon", weapon) | |||
| 	NetProps.SetPropInt(player, "m_iFOV", 0) // fix sniper rifles | |||
| 	player.HandleTauntCommand(0) | |||
| 	NetProps.SetPropEntity(player, "m_hActiveWeapon", active_weapon) | |||
| 	weapon.Kill() | |||
| } | |||
| </syntaxhighlight> | |||
| == Creating Mannpower powerups == | |||
| Creating any type of Mannpower powerup other than Strength is usually not possible manually. However, players are able to drop powerups, which can therefore be manipulated to drop any kind of powerup. The following example abstracts this to allow creating a powerup with a given position, angles, velocity, team and type. The powerup can also be set not to "re-position" (blink and disappear) after time has passed, unlike usual dropped powerups. | |||
| Example usage: | |||
| <syntaxhighlight lang=js> | |||
| CreateRune(Vector(0, 0, -100), QAngle(0, 0, 0), Vector(-500, 0, 0), Constants.ETFTeam.TEAM_ANY, ERuneTypes.RUNE_VAMPIRE, false) | |||
| </syntaxhighlight> | |||
| <br> | |||
| {{note|This code converts existing powerup classnames from <code>item_powerup_rune</code> to <code>zitem_powerup_rune</code> for speed.}} | |||
| <syntaxhighlight lang=js> | |||
| enum ERuneTypes | |||
| { | |||
| 	RUNE_STRENGTH, | |||
| 	RUNE_HASTE, | |||
| 	RUNE_REGEN, | |||
| 	RUNE_RESIST, | |||
| 	RUNE_VAMPIRE, | |||
| 	RUNE_REFLECT, | |||
| 	RUNE_PRECISION, | |||
| 	RUNE_AGILITY, | |||
| 	RUNE_KNOCKOUT, | |||
| 	RUNE_KING, | |||
| 	RUNE_PLAGUE, | |||
| 	RUNE_SUPERNOVA | |||
| } | |||
| ::RuneTypeToCond <- | |||
| [ | |||
| 	Constants.ETFCond.TF_COND_RUNE_STRENGTH, | |||
| 	Constants.ETFCond.TF_COND_RUNE_HASTE, | |||
| 	Constants.ETFCond.TF_COND_RUNE_REGEN, | |||
| 	Constants.ETFCond.TF_COND_RUNE_RESIST, | |||
| 	Constants.ETFCond.TF_COND_RUNE_VAMPIRE, | |||
| 	Constants.ETFCond.TF_COND_RUNE_REFLECT, | |||
| 	Constants.ETFCond.TF_COND_RUNE_PRECISION, | |||
| 	Constants.ETFCond.TF_COND_RUNE_AGILITY, | |||
| 	Constants.ETFCond.TF_COND_RUNE_KNOCKOUT, | |||
| 	Constants.ETFCond.TF_COND_RUNE_KING, | |||
| 	Constants.ETFCond.TF_COND_RUNE_PLAGUE, | |||
| 	Constants.ETFCond.TF_COND_RUNE_SUPERNOVA | |||
| ] | |||
| ::MaxPlayers <- MaxClients().tointeger() | |||
| ::CreateRune <- function(origin, angles, velocity, team, type, reposition) | |||
| { | |||
| 	// select random player to create a rune from | |||
| 	// prioritize players with no rune, as stripping the rune temporarily can have side effects | |||
| 	local player, fallback | |||
| 	for (local i = 1; i <= MaxPlayers; i++) | |||
| 	{ | |||
| 		player = PlayerInstanceFromIndex(i) | |||
| 		if (player) | |||
| 		{ | |||
| 			if (player.IsCarryingRune()) | |||
| 			{ | |||
| 				fallback = player | |||
| 				player = null | |||
| 				continue | |||
| 			} | |||
| 			break | |||
| 		} | |||
| 	} | |||
| 	if (!player) | |||
| 	{ | |||
| 		if (!fallback) | |||
| 			return null | |||
| 		player = fallback | |||
| 	} | |||
| 	// to detect the rune that was spawned, every existing rune must be hidden | |||
| 	for (local rune; rune = Entities.FindByClassname(rune, "item_powerup_rune");) | |||
| 		rune.KeyValueFromString("classname", "zitem_powerup_rune") | |||
| 	local cond = RuneTypeToCond[type] | |||
| 	// if player already has a rune, temporarily strip it | |||
| 	local player_cond, player_cond_duration | |||
| 	if (player.IsCarryingRune()) | |||
| 	{ | |||
| 		foreach (cond in RuneTypeToCond) | |||
| 		{ | |||
| 			player_cond_duration = player.GetCondDuration(cond) | |||
| 			if (player_cond_duration != 0.0) | |||
| 			{ | |||
| 				player.RemoveCond(cond) | |||
| 				player_cond = cond | |||
| 				break | |||
| 			} | |||
| 		} | |||
| 	} | |||
| 	local cond_prop = "m_Shared." + (cond >= 96 ? "m_nPlayerCondEx3" : "m_nPlayerCondEx2") | |||
| 	local cond_bits = NetProps.GetPropInt(player, cond_prop) | |||
| 	NetProps.SetPropInt(player, cond_prop, cond_bits | (1 << (cond % 32))) | |||
| 	player.DropRune(false, team) | |||
| 	NetProps.SetPropInt(player, cond_prop, cond_bits) | |||
| 	// give original rune back | |||
| 	if (player_cond) | |||
| 		player.AddCondEx(player_cond, player_cond_duration, null) | |||
| 	local rune = Entities.FindByClassname(null, "item_powerup_rune") | |||
| 	if (!rune) | |||
| 		return null | |||
| 	rune.Teleport(true, origin, true, angles, true, velocity) | |||
| 	if (!reposition) | |||
| 	{ | |||
| 		// prevents rune from blinking after 30 or 60 seconds | |||
| 		// and teleporting to a spawnpoint if one exists | |||
| 		rune.AddEFlags(Constants.FEntityEFlags.EFL_NO_THINK_FUNCTION) | |||
| 		rune.AddSolidFlags(Constants.FSolid.FSOLID_TRIGGER) | |||
| 	} | |||
| 	return rune | |||
| } | |||
| </syntaxhighlight> | |||
| == Detecting melee smacks / creating projectiles with damage == | |||
| This example demonstrates how to reliably detect when a melee has fired, by abusing a quirk in one of the melee's internal variables. When a Heavy attacks with their fists, they will emit a fireball like from the Dragon's Fury. Therefore this example also demonstrates how to create projectiles that deal damage which can be problematic by usual means. | |||
| <syntaxhighlight lang=js> | |||
| const ID_DRAGONS_FURY = 1178 | |||
| const TF_AMMO_PRIMARY = 1 | |||
| const TF_CLASS_HEAVYWEAPONS = 6 | |||
| const MAX_WEAPONS = 8 | |||
| if (!("FireballMaker" in getroottable()) || !FireballMaker.IsValid()) | |||
| { | |||
| 	// spawn a fake dragon's fury to emit the fireballs | |||
| 	FireballMaker <- Entities.CreateByClassname("tf_weapon_rocketlauncher_fireball") | |||
| 	NetProps.SetPropInt(FireballMaker, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", ID_DRAGONS_FURY) | |||
| 	NetProps.SetPropBool(FireballMaker, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	FireballMaker.DispatchSpawn() | |||
| 	FireballMaker.SetClip1(-1) | |||
| } | |||
| function CheckMeleeSmack() | |||
| { | |||
| 	local owner = self.GetOwner() | |||
| 	// when melee smacks, m_iNextMeleeCrit is 0 | |||
| 	if (NetProps.GetPropInt(owner, "m_Shared.m_iNextMeleeCrit") == 0) | |||
| 	{ | |||
| 		// when switching away from melee, m_iNextMeleeCrit will also be 0 so check for that case | |||
| 		if (owner.GetActiveWeapon() == self) | |||
| 		{ | |||
| 			// preserve old charge meter and ammo count | |||
| 			local charge = NetProps.GetPropFloat(owner, "m_Shared.m_flItemChargeMeter") | |||
| 			local ammo = NetProps.GetPropIntArray(owner, "m_iAmmo", TF_AMMO_PRIMARY) | |||
| 			// set up stuff needed to ensure the weapon always fires | |||
| 			NetProps.SetPropIntArray(owner, "m_iAmmo", 99, TF_AMMO_PRIMARY) | |||
| 			NetProps.SetPropFloat(owner, "m_Shared.m_flItemChargeMeter", 100.0) | |||
| 			NetProps.SetPropBool(owner, "m_bLagCompensation", false) | |||
| 			NetProps.SetPropFloat(FireballMaker, "m_flNextPrimaryAttack", 0) | |||
| 			NetProps.SetPropEntity(FireballMaker, "m_hOwner", owner) | |||
| 			FireballMaker.PrimaryAttack() | |||
| 			// revert changes | |||
| 			NetProps.SetPropBool(owner, "m_bLagCompensation", true) | |||
| 			NetProps.SetPropIntArray(owner, "m_iAmmo", ammo, TF_AMMO_PRIMARY) | |||
| 			NetProps.SetPropFloat(owner, "m_Shared.m_flItemChargeMeter", charge) | |||
| 		} | |||
| 		// continue smack detection | |||
| 		NetProps.SetPropInt(owner, "m_Shared.m_iNextMeleeCrit", -2) | |||
| 	} | |||
| 	return -1 | |||
| } | |||
| // event listener (see Listening for Events example) | |||
| CollectEventsInScope | |||
| ({ | |||
| 	OnGameEvent_player_spawn = function(params) | |||
| 	{ | |||
| 		local player = GetPlayerFromUserID(params.userid) | |||
| 		local player_class = params["class"] | |||
| 		if (player_class != TF_CLASS_HEAVYWEAPONS) | |||
| 			return | |||
| 		for (local i = 0; i < MAX_WEAPONS; i++) | |||
| 		{ | |||
| 			local weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i) | |||
| 			if (weapon == null || !weapon.IsMeleeWeapon()) | |||
| 				continue | |||
| 			// this kicks off melee smack detection logic | |||
| 			NetProps.SetPropInt(player, "m_Shared.m_iNextMeleeCrit", -2) | |||
| 			AddThinkToEnt(weapon, "CheckMeleeSmack") | |||
| 			break | |||
| 		} | |||
| 	} | |||
| }) | }) | ||
| </syntaxhighlight> | |||
| == Detecting weapon firing == | |||
| Similar to above example, on all weapons except melee there is another variable that can be tracked. This example pushes the player back when they fire their weapon. | |||
| <syntaxhighlight lang=js> | |||
| const MAX_WEAPONS = 8 | |||
| function CheckWeaponFire() | |||
| { | |||
| 	local fire_time = NetProps.GetPropFloat(self, "m_flLastFireTime") | |||
| 	if (fire_time > last_fire_time) | |||
| 	{ | |||
| 		printf("%f %s : Fired\n", Time(), self.GetClassname()) | |||
| 		local owner = self.GetOwner() | |||
| 		if (owner) | |||
| 		{ | |||
| 			owner.SetAbsVelocity(owner.GetAbsVelocity() - owner.EyeAngles().Forward() * 800.0) | |||
| 		} | |||
| 		last_fire_time = fire_time | |||
| 	} | |||
| 	return -1 | |||
| } | |||
| // event listener (see Listening for Events example) | |||
| CollectEventsInScope | |||
| ({ | |||
| 	OnGameEvent_player_spawn = function(params) | |||
| 	{ | |||
| 		local player = GetPlayerFromUserID(params.userid) | |||
| 		if (!player) | |||
| 			return | |||
| 		for (local i = 0; i < MAX_WEAPONS; i++) | |||
| 		{ | |||
| 			local weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i) | |||
| 			if (weapon == null || weapon.IsMeleeWeapon()) | |||
| 				continue | |||
| 			weapon.ValidateScriptScope() | |||
| 			weapon.GetScriptScope().last_fire_time <- 0.0 | |||
| 			weapon.GetScriptScope().CheckWeaponFire <- CheckWeaponFire | |||
| 			AddThinkToEnt(weapon, "CheckWeaponFire") | |||
| 		} | |||
| 	} | |||
| }) | |||
| </syntaxhighlight> | |||
| == Disabling Medic health regen == | |||
| This effectively cancels out the passive health regeneration from a medic. | |||
| <syntaxhighlight lang=js> | |||
| function OnGameEvent_player_healed(params) | |||
| { | |||
| 	if (params.patient == params.healer) | |||
| 	{ | |||
| 		local player = GetPlayerFromUserID(params.patient) | |||
| 		if (player.GetPlayerClass() == Constants.ETFClass.TF_CLASS_MEDIC | |||
| 			&& NetProps.GetPropInt(player, "m_bitsDamageType") == 32) | |||
| 		{ | |||
| 			player.SetHealth(player.GetHealth() - params.amount) | |||
| 		} | |||
| 	} | |||
| } | |||
| </syntaxhighlight> | |||
| == Special death effects on triggers == | |||
| Weapons that have special death effects (such as disintegration from Phlogistinator, or the ice statue from Spy-cicle) can be applied to triggers via two methods: | |||
| === Using the <code>TakeDamageCustom</code> function === | |||
| This method will work for most [[Team_Fortress_2/Scripting/Script_Functions/Constants#ETFDmgCustom|listed custom damage types]], e.g. decapitation or Cow Mangler's dissolve effect. Otherwise, the other method must be used. | |||
| Add the following output to a <code>trigger_hurt</code>. Replace 9999 with the desired damage and <code>TF_DMG_CUSTOM</code> part with the desired effect. | |||
| <syntaxhighlight> | |||
| OnStartTouch | !activator | RunScriptCode | self.TakeDamageCustom(activator, activator, null, Vector(), Vector(), 9999, 0, Constants.ETFDmgCustom.TF_DMG_CUSTOM_PLASMA) | |||
| </syntaxhighlight> | |||
| === Using a dummy weapon === | |||
| Some death effects are not available as a custom damage type, but they can be applied by using a spoofed weapon as the damage owner. | |||
| The example below shows how to create a trigger that kills players with a disintegration effect when touched. This script can be assigned to any trigger entity. Note that if the trigger is not a <code>trigger_hurt</code>, Engineer buildings will be possible to place inside. This can be workarounded by placing a [[func_nobuild]] in it's place as well. | |||
| <syntaxhighlight lang=js> | |||
| const ID_SUN_ON_A_STICK = 349 | |||
| // Ensure only one of this entity is ever spawned | |||
| if (!("disintegrate_proxy_weapon" in getroottable())) | |||
| { | |||
| 	::disintegrate_proxy_weapon <- null | |||
| 	::disintegrate_immune_conds <- | |||
| 	[ | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE, | |||
| 		Constants.ETFCond.TF_COND_PHASE, | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE_HIDE_UNLESS_DAMAGED, | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE_USER_BUFF, | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE_CARD_EFFECT, | |||
| 	] | |||
| } | |||
| // Create the fake weapon if one doesn't already exist | |||
| if (!disintegrate_proxy_weapon || !disintegrate_proxy_weapon.IsValid()) | |||
| { | |||
| 	disintegrate_proxy_weapon = Entities.CreateByClassname("tf_weapon_bat") | |||
| 	NetProps.SetPropInt(disintegrate_proxy_weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", ID_SUN_ON_A_STICK) | |||
| 	NetProps.SetPropBool(disintegrate_proxy_weapon, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	disintegrate_proxy_weapon.DispatchSpawn() | |||
| 	disintegrate_proxy_weapon.DisableDraw() | |||
| 	// Add the attribute that creates disintegration | |||
| 	disintegrate_proxy_weapon.AddAttribute("ragdolls become ash", 1, -1) | |||
| } | |||
| function Precache() | |||
| { | |||
| 	// Add an output to deal damage when the trigger is touched | |||
| 	self.ConnectOutput("OnStartTouch", "Disintegrate") | |||
| } | |||
| function Disintegrate() | |||
| { | |||
| 	// Remove conditions that give immunity to damage | |||
| 	foreach (cond in disintegrate_immune_conds) | |||
| 		activator.RemoveCondEx(cond, true) | |||
| 	// Set any owner on the weapon to prevent a crash | |||
| 	NetProps.SetPropEntity(disintegrate_proxy_weapon, "m_hOwner", activator) | |||
| 	// Deal the damage with the weapon | |||
| 	activator.TakeDamageCustom(self, activator, disintegrate_proxy_weapon, | |||
| 								Vector(0,0,0), Vector(0,0,0), | |||
| 								99999.0, 2080, Constants.ETFDmgCustom.TF_DMG_CUSTOM_BURNING) | |||
| } | |||
| </syntaxhighlight> | |||
| Similar example but creates an ice statue on death instead. | |||
| <syntaxhighlight lang=js> | |||
| // Ensure only one of this entity is ever spawned | |||
| if (!("freeze_proxy_weapon" in getroottable())) | |||
| { | |||
| 	::freeze_proxy_weapon <- null | |||
| 	::freeze_immune_conds <- | |||
| 	[ | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE, | |||
| 		Constants.ETFCond.TF_COND_PHASE, | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE_HIDE_UNLESS_DAMAGED, | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE_USER_BUFF, | |||
| 		Constants.ETFCond.TF_COND_INVULNERABLE_CARD_EFFECT, | |||
| 	] | |||
| } | |||
| // Create the fake weapon if one doesn't already exist | |||
| if (!freeze_proxy_weapon || !freeze_proxy_weapon.IsValid()) | |||
| { | |||
| 	freeze_proxy_weapon = Entities.CreateByClassname("tf_weapon_knife") | |||
| 	NetProps.SetPropInt(freeze_proxy_weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 649) | |||
| 	NetProps.SetPropBool(freeze_proxy_weapon, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	freeze_proxy_weapon.DispatchSpawn() | |||
| 	freeze_proxy_weapon.DisableDraw() | |||
| 	// Add the attribute that creates ice statues | |||
| 	freeze_proxy_weapon.AddAttribute("freeze backstab victim", 1.0, -1.0) | |||
| } | |||
| function Precache() | |||
| { | |||
| 	// Add an output to deal damage when the trigger is touched | |||
| 	self.ConnectOutput("OnStartTouch", "Freeze") | |||
| } | |||
| function Freeze() | |||
| { | |||
| 	// Remove conditions that give immunity to damage | |||
| 	foreach (cond in freeze_immune_conds) | |||
| 		activator.RemoveCondEx(cond, true) | |||
| 	// Set any owner on the weapon to prevent a crash | |||
| 	NetProps.SetPropEntity(freeze_proxy_weapon, "m_hOwner", activator) | |||
| 	// Deal the damage with the weapon | |||
| 	activator.TakeDamageCustom(self, activator, freeze_proxy_weapon, | |||
| 								Vector(0,0,0), Vector(0,0,0), | |||
| 								99999.0, Constants.FDmgType.DMG_CLUB, Constants.ETFDmgCustom.TF_DMG_CUSTOM_BACKSTAB) | |||
| 	// I don't remember why this is needed but it's important | |||
| 	local ragdoll = NetProps.GetPropEntity(activator, "m_hRagdoll") | |||
| 	if (ragdoll) | |||
| 		NetProps.SetPropInt(ragdoll, "m_iDamageCustom", 0) | |||
| } | |||
| </syntaxhighlight> | |||
| == Improving player collisions on physics objects or trains == | |||
| {{tf2|2}} has a subdued physics mode by default, which makes players poorly interact with physics objects unlike other Source games. This can be fixed by setting the <code>sv_turbophysics</code> [[Team_Fortress_2/Script_Functions#Convars|convar]] to 0. | |||
| <syntaxhighlight lang=js> | |||
| Convars.SetValue("sv_turbophysics", 0) | |||
| </syntaxhighlight> | |||
| [[func_tracktrain]]s have similar problems. The game has a special train physics mode for Payload to circumvent this issue, which is normally not enabled in other gamemodes. This can be forcefully enabled (or disabled!) via the following: | |||
| <syntaxhighlight lang=js> | |||
| ForceEscortPushLogic(2) | |||
| </syntaxhighlight> | |||
| == Force class change == | |||
| When doing server-only scripts via mapspawn.nut, calling SetPlayerClass can either plain not work or work buggily. For example, looking at your loadout shows the class you tried to choose, rather than forced class and each time you spawn your class will revert back to what you tried to select. By also updating m_iDesiredPlayerClass, these aforementioned bugs no longer occur. | |||
| <syntaxhighlight lang=js> | |||
| ::ForceChangeClass <- function(player, class_index) | |||
| { | |||
| 	player.SetPlayerClass(class_index) | |||
| 	NetProps.SetPropInt(player, "m_Shared.m_iDesiredPlayerClass", class_index) | |||
| 	player.ForceRegenerateAndRespawn() | |||
| } | |||
| ForceChangeClass(GetListenServerHost(), Constants.ETFClass.TF_CLASS_SCOUT) | |||
| </syntaxhighlight> | |||
| == Change how long it takes to capture a control point == | |||
| When doing server-only scripts via mapspawn.nut, you may want to increase the amount of time it takes to capture a control point. The following snippet increases the time it takes to capture a control point by 2x. | |||
| <syntaxhighlight lang=js> | |||
| function OnGameEvent_teamplay_round_start(params) | |||
| { | |||
| 	// Must set this when the map resets only, otherwise the scaling compounds between rounds | |||
| 	// If this is set elsewhere, it may not work as the m_flCapTime gets reset. | |||
| 	if (params.full_reset) | |||
| 	{ | |||
| 		for (local ent = null; ent = Entities.FindByClassname(ent, "trigger_capture_area");) | |||
| 		{ | |||
| 			local cap_point_name = NetProps.GetPropString(ent, "m_iszCapPointName") | |||
| 			local cap_time_in_seconds = NetProps.GetPropFloat(ent, "m_flCapTime") | |||
| 			// Double the time it takes to capture a control point | |||
| 			cap_time_in_seconds *= 2.0 | |||
| 			NetProps.SetPropFloat(ent, "m_flCapTime", cap_time_in_seconds) | |||
| 			// If we do not fire this event, then cap time total does not propagate to the client | |||
| 			// and the UI will be buggy and show the control point capping quicker then it should | |||
| 			// and then "popping" back after the server updates the client. | |||
| 			ent.AcceptInput("SetControlPoint", cap_point_name, null, null) | |||
| 		} | |||
| 	} | |||
| } | |||
| </syntaxhighlight> | |||
| == Making a model visible to only to a specific player == | |||
| In the game, entities are usually networked to every player. It may be useful to show a model to a specific player only, which is what this example allows. Engineer buildings only show to their owner during their "blueprint" phase, and this fact can be abused to make a model only show to a desired player. | |||
| <syntaxhighlight lang=js> | |||
| local proxy_entity = Entities.CreateByClassname("obj_teleporter") // not using SpawnEntityFromTable as that creates spawning noises | |||
| proxy_entity.SetAbsOrigin(Vector(0, -320, -150)) | |||
| proxy_entity.DispatchSpawn() | |||
| proxy_entity.SetModel("models/player/heavy.mdl") | |||
| proxy_entity.AddEFlags(Constants.FEntityEFlags.EFL_NO_THINK_FUNCTION) // prevents the entity from disappearing | |||
| proxy_entity.SetSolid(Constants.ESolidType.SOLID_NONE) | |||
| NetProps.SetPropBool(proxy_entity, "m_bPlacing", true) | |||
| NetProps.SetPropInt(proxy_entity, "m_fObjectFlags", 2) // sets "attachment" flag, prevents entity being snapped to player feet | |||
| // m_hBuilder is the player who the entity will be networked to only | |||
| NetProps.SetPropEntity(proxy_entity, "m_hBuilder", GetListenServerHost()) | |||
| </syntaxhighlight> | |||
| == Enabling the Spell HUD and Effects == | |||
| By setting the <code>m_bIsUsingSpells</code> netprop to true, you can Enable the spell HUD, the sound effects for rolling spells, and set the spellbook magazine to the default action slot item.  Note that spells do work when this netprop is set to false, however the HUD elements/sound effects will not appear, and players will need to manually equip the spellbook if they have not already. | |||
| <syntaxhighlight lang=js> | |||
| ::SetSpellHUD <- function(value) | |||
| { | { | ||
| 	local gamerules = Entities.FindByClassname(null, "tf_gamerules") | |||
| 	NetProps.SetPropBool(gamerules, "m_bIsUsingSpells", value) | |||
| 	local  | |||
| } | } | ||
| SetSpellHUD(true) | |||
| </syntaxhighlight> | |||
| == Changing Player Teams in MvM == | |||
| When attempting to change to change the player to the blue team in MvM using <code>ForceChangeTeam</code> or <code>SetTeam</code>, they will be immediately switched back to RED.  To circumvent this, you can temporarily change the tf_gamerules NetProp <code>m_bPlayingMannVsMachine</code> to false, switch the player team, then set it back to true immediately after.  Note that you will need to manually set the robot models for players if you intend to make playable robots. | |||
| {{warning|Not setting this netprop back to true immediately after switching teams can cause many problems, including server crashes!}} | |||
| <syntaxhighlight lang=js> | |||
| ::ChangePlayerTeamMvM <- function(player, teamnum) | |||
| { | |||
| 	local gamerules = Entities.FindByClassname(null, "tf_gamerules") | |||
| </ | 	NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", false) | ||
| 	player.ForceChangeTeam(teamnum, false) | |||
| 	NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", true) | |||
| } | |||
| ChangePlayerTeamMvM(GetListenServerHost(), Constants.ETFTeam.TF_TEAM_PVE_INVADERS) //Constants.ETFTeam.TF_TEAM_BLUE is also perfectly valid | |||
| </syntaxhighlight> | |||
| ==  | == Changing MvM Mission Names on the Scoreboard == | ||
| You can change the displayed mission name on the scoreboard by changing the <code>m_iszMvMPopfileName</code> netprop on the [[tf_objective_resource]] entity. | |||
| {{Note|This will always appear in uppercase on the scoreboard, as the game is expecting a file path to localize.}} | |||
| <syntaxhighlight lang=js> | |||
| local resource = Entities.FindByClassname(null, "tf_objective_resource") | |||
| NetProps.SetPropString(resource, "m_iszMvMPopfileName", "New Mission Name Here") | |||
| </syntaxhighlight> | |||
| == Disabling Thriller taunt in Halloween == | |||
| {{note|A better way is to just add the <code>special taunt</code> attribute to all of the player's weapons, as that always prevents the Thriller taunt. The old way had a rare chance of failing to prevent the taunt. That old example remains here for prosperity.}} | |||
| The following think function on a player will disable the Thriller taunt on Halloween mode and play the normal taunt instead. | |||
| <syntaxhighlight lang=js> | |||
| const TF_CLASS_MEDIC = 5 | |||
| const TF_COND_TAUNTING = 7 | |||
| const TAUNT_BASE_WEAPON = 0 | |||
| function NoThrillerThink() | |||
| { | |||
| 	if (!self.IsTaunting()) | |||
| 		return -1 | |||
| 	for (local scene; scene = Entities.FindByClassname(scene, "instanced_scripted_scene");) | |||
| 	{ | |||
| 		local owner = NetProps.GetPropEntity(scene, "m_hOwner") | |||
| 		if (owner != self) | |||
| 			continue | |||
| 		local name = NetProps.GetPropString(scene, "m_szInstanceFilename") | |||
| 		local thriller_name = self.GetPlayerClass() == TF_CLASS_MEDIC ? "taunt07" : "taunt06" | |||
| 		if (name.find(thriller_name) == null) | |||
| 			continue | |||
| 		scene.Kill() | |||
| 		self.RemoveCond(TF_COND_TAUNTING) | |||
| 		self.Taunt(TAUNT_BASE_WEAPON, 0) | |||
| 		break | |||
| 	} | |||
| 	return -1 | |||
| } | |||
| </syntaxhighlight> | |||
| == Applying paints to any entity == | |||
| Cosmetic paints can be applied to any entity such as [[prop_dynamic]] by setting the owner entity to a [[tf_wearable]] (or any other econ entity). | |||
| See [https://steamcommunity.com/sharedfiles/filedetails/?id=1911160067 this guide] for a list of paint values (under ''Painting Robot Items''). | |||
| <syntaxhighlight lang=js> | |||
| local model_name = "models/player/items/demo/demo_ellis.mdl" | |||
| local origin = GetListenServerHost().GetOrigin() | |||
| local paint = 15132390 | |||
| local wearable = Entities.CreateByClassname("tf_wearable") | |||
| NetProps.SetPropBool(wearable, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| wearable.SetAbsOrigin(origin) | |||
| wearable.AddAttribute("set item tint RGB", paint, -1) | |||
| wearable.DispatchSpawn() | |||
| wearable.EnableDraw() | |||
| local prop = SpawnEntityFromTable("prop_dynamic", | |||
| { | |||
| 	origin = origin, | |||
| 	model = model_name | |||
| }) | |||
| prop.SetOwner(wearable) | |||
| </syntaxhighlight> | |||
| == Readying up teams for mp_tournament 1 in offline == | |||
| Gamemodes for Casual must be tested with mp_tournament 1 and mp_tournament_stopwatch 0 to be considered for inclusion. However this usually requires 2 real players to ready up for their team, which may be undesirable for offline testing. As a workaround, the following code snippet can be executed to pick any player from both teams and spoof readying up. | |||
| <syntaxhighlight lang=js> | |||
| ::GameRules <- Entities.FindByClassname(null, "tf_gamerules") | |||
| ::MAX_CLIENTS <- MaxClients().tointeger() | |||
| const TF_TEAM_RED = 2 | |||
| const TF_TEAM_BLUE = 3 | |||
| local ready = true | |||
| local red_check = false | |||
| local blue_check = false | |||
| for (local i = 1; i <= MAX_CLIENTS; i++) | |||
| { | |||
| 	local player = PlayerInstanceFromIndex(i) | |||
| 	if (!player) | |||
| 		continue | |||
| 	local team = player.GetTeam() | |||
| 	if (!(team & 2)) | |||
| 		continue | |||
| 	if (team == TF_TEAM_RED) | |||
| 	{ | |||
| 		if (red_check) | |||
| 			continue | |||
| 		red_check = true | |||
| 	} | |||
| 	else if (team == TF_TEAM_BLUE) | |||
| 	{ | |||
| 		if (blue_check) | |||
| 			continue | |||
| 		blue_check = true | |||
| 	} | |||
| 	if (NetProps.GetPropBoolArray(GameRules, "m_bTeamReady", team)) | |||
| 		continue | |||
| 	NetProps.SetPropBoolArray(GameRules, "m_bTeamReady", ready, team) | |||
| 	SendGlobalGameEvent("tournament_stateupdate", | |||
| 	{ | |||
| 		userid = player.entindex(), | |||
| 		readystate = ready.tointeger(), | |||
| 		namechange = 0, | |||
| 		oldname = " ", | |||
| 		newname = " ", | |||
| 	}) | |||
| 	if (!ready) | |||
| 	{ | |||
| 		NetProps.SetPropFloat(GameRules, "m_flRestartRoundTime", -1.0) | |||
| 		NetProps.SetPropBool(GameRules, "m_bAwaitingReadyRestart", true) | |||
| 	} | |||
| } | |||
| </syntaxhighlight> | |||
| == Forcing a level change == | |||
| The code snippet below will force a level change after one second to the next map in the rotation. | |||
| <source lang=js> | |||
| function ChangeLevel(mapname = "", delay = 1.0, mvm_cyclemissionfile = false) | |||
| { | |||
| 	// listen servers can just do this | |||
| 	if (!IsDedicatedServer()) | |||
| 		SendToConsole(format("nextlevel %s", mapname)) | |||
| 	// check for allow point_servercommand | |||
| 	else if (Convars.GetStr("sv_allow_point_servercommand") == "always") | |||
| 		SendToServerConsole(format("nextlevel %s", mapname)) | |||
| 	// check the allowlist | |||
| 	else if (Convars.IsConVarOnAllowList("nextlevel")) | |||
| 		Convars.SetValue("nextlevel", mapname) | |||
| 	// can't set it, just load the next map in the mapcycle file or hope the server sets nextlevel for us | |||
| 	else | |||
| 		printl("cannot set nextlevel! loading next map instead...") | |||
| 	// required for GoToIntermission | |||
| 	Convars.SetValue("mp_tournament", 0) | |||
| 	// wait at scoreboard for this many seconds | |||
| 	Convars.SetValue("mp_chattime", delay) | |||
| 	local intermission = Entities.CreateByClassname("point_intermission") | |||
| 	// for mvm, otherwise it'll ignore delay and switch to the next map in the missioncycle file | |||
| 	if (!mvm_cyclemissionfile) | |||
| 		EntFire("info_populator", "Kill") | |||
| 	// don't use acceptinput so we execute after info_populator kill in mvm | |||
| 	EntFireByHandle(intermission, "Activate", "", -1, null, null) | |||
| } | |||
| </source> | |||
| By default the next level cannot be controlled, as the <code>nextlevel</code> ConVar is not in cfg/vscript_convar_allowlist.txt by default. | |||
| If the server has not added <code>nextlevel</code> to their allow list, this can be worked around if the server has <code>sv_allow_point_servercommand</code> set to <code>always</code>.  On listen servers only you can also use <code>SendToConsole</code> which will always work. | |||
| {{Note|In Mann vs Machine, this will instead cycle to the next map set in the servers <code>tf_mvm_missioncyclefile</code> and will immediately skip the <code>mp_chattime</code> delay, killing the [[info_populator]] ent first will circumvent this.  The above snippet does this by default but can be controlled in the third argument. | |||
|  E.g. <code>ChangeLevel("", 1.0, true)</code>}} | |||
| == Placing Sapper on a Building == | |||
| Placing a sapper on an entity isn't supposed to be done by manual means, however it can be spoofed by selecting a random player and pretending they "sapped" the building, and reverting everything on the same frame which makes it look seamless. | |||
| {{warning|This has not been tested too much. There could be more edge cases where a player can't place a Sapper which need to be resolved.}} | |||
| {{todo|The weapon is holstered each time, fixing this requires manually spoofing <code>Weapon_Switch</code>.}} | |||
| <syntaxhighlight lang=js> | |||
| const MAX_WEAPONS = 8 | |||
| const TF_CLASS_SPY = 8 | |||
| const TF_CLASS_ENGINEER = 9 | |||
| const TF_TEAM_RED = 2 | |||
| const TF_TEAM_BLUE = 3 | |||
| const LIFE_ALIVE = 0 | |||
| const ID_SAPPER = 735 | |||
| const OBJ_ATTACHMENT_SAPPER = 3 | |||
| function SapBuilding(building) | |||
| { | |||
| 	// select first player found | |||
| 	local player = Entities.FindByClassname(null, "player") | |||
| 	if (!player) | |||
| 		return | |||
| 	// find existing builder weapon if applicable | |||
| 	local player_class = player.GetPlayerClass() | |||
| 	local old_builder, old_slot | |||
| 	if (player_class == TF_CLASS_SPY || player_class == TF_CLASS_ENGINEER) | |||
| 	{ | |||
| 		for (local i = 0; i < MAX_WEAPONS; i++) | |||
| 		{ | |||
| 			local held_weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i) | |||
| 			if (held_weapon == null) | |||
| 				continue | |||
| 			if (held_weapon.GetClassname() != "tf_weapon_builder") | |||
| 				continue | |||
| 			old_builder = held_weapon | |||
| 			old_slot = i | |||
| 			NetProps.SetPropEntityArray(player, "m_hMyWeapons", null, i) | |||
| 			break | |||
| 		} | |||
| 	} | |||
| 	local old_lifestate = NetProps.GetPropInt(player, "m_lifeState") | |||
| 	local old_team = player.GetTeam() | |||
| 	local building_team = building.GetTeam() | |||
| 	local enemy_team | |||
| 	if (building_team == TF_TEAM_RED) | |||
| 		enemy_team = TF_TEAM_BLUE | |||
| 	else | |||
| 		enemy_team = TF_TEAM_RED | |||
| 	// spoof being alive and on opposite team | |||
| 	NetProps.SetPropInt(player, "m_lifeState", LIFE_ALIVE) | |||
| 	NetProps.SetPropInt(player, "m_iTeamNum", enemy_team) | |||
| 	// give sapper weapon | |||
| 	local weapon = Entities.CreateByClassname("tf_weapon_builder") | |||
| 	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", ID_SAPPER) | |||
| 	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true) | |||
| 	NetProps.SetPropBool(weapon, "m_bValidatedAttachedEntity", true) | |||
| 	weapon.SetTeam(enemy_team) | |||
| 	weapon.DispatchSpawn() | |||
| 	NetProps.SetPropInt(weapon, "m_iObjectType", OBJ_ATTACHMENT_SAPPER) | |||
| 	NetProps.SetPropInt(weapon, "m_iSubType", OBJ_ATTACHMENT_SAPPER) | |||
| 	NetProps.SetPropIntArray(weapon, "m_aBuildableObjectTypes", 1, OBJ_ATTACHMENT_SAPPER) | |||
| 	player.Weapon_Equip(weapon) | |||
| 	// teleport player to the building and hold the sapper | |||
| 	local old_origin = player.GetOrigin() | |||
| 	local old_angles = player.LocalEyeAngles() | |||
| 	local old_weapon = player.GetActiveWeapon() | |||
| 	player.SetAbsOrigin(building.GetOrigin() - Vector(64, 0, 0)) | |||
| 	NetProps.SetPropVector(player, "pl.v_angle", Vector()) | |||
| 	player.Weapon_Switch(weapon) | |||
| 	weapon.PrimaryAttack() | |||
| 	// remove owner from the sapper that was just placed | |||
| 	for (local sapper; sapper = Entities.FindByClassname(sapper, "obj_attachment_sapper");) | |||
| 	{ | |||
| 		if (NetProps.GetPropEntity(sapper, "m_hBuiltOnEntity") == building) | |||
| 		{ | |||
| 			NetProps.SetPropEntity(sapper, "m_hBuilder", null) | |||
| 			break | |||
| 		} | |||
| 	} | |||
| 	// revert all changes | |||
| 	if (old_builder) | |||
| 		NetProps.SetPropEntityArray(player, "m_hMyWeapons", old_builder, old_slot) | |||
| 	NetProps.SetPropInt(player, "m_lifeState", old_lifestate) | |||
| 	NetProps.SetPropInt(player, "m_iTeamNum", old_team) | |||
| 	player.Weapon_Switch(old_weapon) | |||
| 	NetProps.SetPropVector(player, "pl.v_angle", old_angles + Vector()) | |||
| 	player.SetAbsOrigin(old_origin) | |||
| 	weapon.Destroy() | |||
| } | |||
| // example | |||
| local building = Entities.FindByClassname(null, "obj_dispenser") | |||
| if (building) | |||
| { | |||
| 	if (!NetProps.GetPropBool(building, "m_bHasSapper")) | |||
| 		SapBuilding(building) | |||
| } | |||
| </syntaxhighlight> | |||
| == Creating bots that use the navmesh == | |||
| The following code show an example of creating a primitive bot that navigates using the [[navmesh]]. | |||
| When this script is executed, a bot will be spawned at the host player's crosshair. This bot will simply follow the player and path-find along the map layout. | |||
| The code is heavily commented, and debug visualizations are available to see the path-finding algorithm in action. | |||
| {{warning|Making bots is not an easy task. This code shows how much work is required just to setup a simple bot!}} | |||
| {{todo|Need to make a complete example with collision testing and obstacle avoidance}} | |||
| <syntaxhighlight lang=js> | |||
| const FLT_MAX = 3.402823466e+38 | |||
| const MASK_SOLID = 33570827 | |||
| const LIFE_DYING = 1 | |||
| const NUM_TRAVERSE_TYPES = 9 | |||
| const TEAM_ANY = -2 | |||
| const DMG_CRUSH = 1 | |||
| // Constrains an angle into [-180, 180] range | |||
| ::NormalizeAngle <- function(target) | |||
| { | |||
| 	target %= 360.0 | |||
| 	if (target > 180.0) | |||
| 		target -= 360.0 | |||
| 	else if (target < -180.0) | |||
| 		target += 360.0 | |||
| 	return target | |||
| } | |||
| // Approaches an angle at a given speed | |||
| ::ApproachAngle <- function(target, value, speed) | |||
| { | |||
| 	target = NormalizeAngle(target) | |||
| 	value = NormalizeAngle(value) | |||
| 	local delta = NormalizeAngle(target - value) | |||
| 	if (delta > speed) | |||
| 		return value + speed | |||
| 	else if (delta < -speed) | |||
| 		return value - speed | |||
| 	return value | |||
| } | |||
| // Converts a vector direction into angles | |||
| ::VectorAngles <- function(forward) | |||
| { | |||
| 	local yaw, pitch | |||
| 	if (forward.y == 0.0 && forward.x == 0.0) | |||
| 	{ | |||
| 		yaw = 0.0 | |||
| 		if (forward.z > 0.0) | |||
| 			pitch = 270.0 | |||
| 		else | |||
| 			pitch = 90.0 | |||
| 	} | |||
| 	else | |||
| 	{ | |||
| 		yaw = (atan2(forward.y, forward.x) * 180.0 / PI) | |||
| 		if (yaw < 0.0) | |||
| 			yaw += 360.0 | |||
| 		pitch = (atan2(-forward.z, forward.Length2D()) * 180.0 / PI) | |||
| 		if (pitch < 0.0) | |||
| 			pitch += 360.0 | |||
| 	} | |||
| 	return QAngle(pitch, yaw, 0.0) | |||
| } | |||
| // Coordinate which is part of a path | |||
| class ::PathPoint | |||
| { | |||
| 	constructor(_area, _pos, _how) | |||
| 	{ | |||
| 		area = _area | |||
| 		pos = _pos | |||
| 		how = _how | |||
| 	} | |||
| 	area = null		// Which area does this point belong to? | |||
| 	pos = null		// Coordinates of the point | |||
| 	how = null		// Type of traversal. See Constants.ENavTraverseType | |||
| } | |||
| // The big boy that handles all our behavior | |||
| class ::Bot | |||
| { | |||
| 	constructor(spawn_origin, target = null) | |||
| 	{ | |||
| 		bot = SpawnEntityFromTable("base_boss", | |||
| 		{ | |||
| 			targetname = "bot", | |||
| 			origin = spawn_origin, | |||
| 			model = "models/bots/heavy/bot_heavy.mdl", | |||
| 			playbackrate = 1.0, // Required for animations to be simulated | |||
| 			// The following is done to prevent default base_boss death behavior | |||
| 			// Set the health to something really big | |||
| 			health = FLT_MAX | |||
| 		}) | |||
| 		// Track the health manually by using npc_hurt event and fire our custom death | |||
| 		locomotion = bot.GetLocomotionInterface() | |||
| 		health = 300 | |||
| 		// Fix the default step height which is too high | |||
| 		bot.AcceptInput("SetStepHeight", "18", null, null) | |||
| 		// Add scope to the entity | |||
| 		bot.ValidateScriptScope() | |||
| 		local scope = bot.GetScriptScope() | |||
| 		// Append custom bot functionality | |||
| 		scope.bot_brain <- this | |||
| 		// Add behavior that will run every tick | |||
| 		scope.Think <- Update.bindenv(this) | |||
| 		AddThinkToEnt(bot, "Think") | |||
| 		bot_pos = spawn_origin | |||
| 		move_speed = 230.0 | |||
| 		turn_rate = 5.0 | |||
| 		search_dist_z = 128.0 | |||
| 		search_dist_nearest = 128.0 | |||
| 		path = [] | |||
| 		path_index = 0 | |||
| 		path_count = 0 | |||
| 		path_reach_dist = 16.0 | |||
| 		path_update_time_next = Time() | |||
| 		path_update_time_delay = 0.2 | |||
| 		path_closest_distance = 100.0 | |||
| 		path_follow_ent_dist = 50.0 | |||
| 		area_list = {} | |||
| 		// If the destination hasn't been set yet make it be at the bot's spawn location | |||
| 		if (target == null) | |||
| 			target = spawn_origin | |||
| 		SetDestination(target) | |||
| 		seq_idle = bot.LookupSequence("Stand_MELEE") | |||
| 		seq_run = bot.LookupSequence("Run_MELEE") | |||
| 		pose_move_x = bot.LookupPoseParameter("move_x") | |||
| 		debug = true | |||
| 	} | |||
| 	function SetDestination(target) | |||
| 	{ | |||
| 		// If our destination is an entity | |||
| 		if (typeof target == "instance") | |||
| 		{ | |||
| 			path_follow_ent = target | |||
| 			// path_target_pos will be calculated in UpdatePath() | |||
| 		} | |||
| 		// If it's a vector | |||
| 		else | |||
| 		{ | |||
| 			path_follow_ent = null | |||
| 			path_target_pos = target | |||
| 		} | |||
| 		// Force a path update on this frame | |||
| 		if (UpdatePath()) | |||
| 			SetDirection(path[0].pos) | |||
| 	} | |||
| 	function SetDirection(pos) | |||
| 	{ | |||
| 		move_pos = pos | |||
| 		// Direction towards path point | |||
| 		move_dir = move_pos - bot_pos | |||
| 		move_dir.Norm() | |||
| 		// Conversion from direction into QAngle form to calculate the bot rotation angles | |||
| 		move_ang = VectorAngles(move_dir) | |||
| 	} | |||
| 	function UpdatePath() | |||
| 	{ | |||
| 		// Clear out the path first | |||
| 		ResetPath() | |||
| 		// If there is a target entity specified, then the bot will pathfind to the entity | |||
| 		if (path_follow_ent && path_follow_ent.IsValid()) | |||
| 			path_target_pos = path_follow_ent.GetOrigin() | |||
| 		// Pathfind from the bot's position to the target position | |||
| 		local pos_start = bot_pos | |||
| 		local pos_end = path_target_pos | |||
| 		local area_start = NavMesh.GetNavArea(pos_start, search_dist_z) | |||
| 		local area_end = NavMesh.GetNavArea(pos_end, search_dist_z) | |||
| 		// If either area was not found, try use the closest one | |||
| 		if (area_start == null) | |||
| 		{ | |||
| 			area_start = NavMesh.GetNearestNavArea(pos_start, search_dist_nearest, false, true) | |||
| 			// If either area is still missing, then bot can't progress | |||
| 			if (area_start == null) | |||
| 				return false | |||
| 		} | |||
| 		if (area_end == null) | |||
| 		{ | |||
| 			area_end = NavMesh.GetNearestNavArea(pos_end, search_dist_nearest, false, true) | |||
| 			if (area_end == null) | |||
| 				return false | |||
| 		} | |||
| 		// If the start and end area is the same, one path point is enough and all the expensive path building can be skipped | |||
| 		if (area_start == area_end) | |||
| 		{ | |||
| 			// From bot's origin | |||
| 			path.append(PathPoint(area_start, pos_start, NUM_TRAVERSE_TYPES)) | |||
| 			// To target's position | |||
| 			path.append(PathPoint(area_end, pos_end, NUM_TRAVERSE_TYPES)) | |||
| 			path_count = 2 | |||
| 			// For the debug mode | |||
| 			area_list["area0"] <- area_start | |||
| 			return true | |||
| 		} | |||
| 		// Build list of areas required to get from the start to the end | |||
| 		if (!NavMesh.GetNavAreasFromBuildPath(area_start, area_end, pos_end, 0.0, TEAM_ANY, false, area_list)) | |||
| 			return false | |||
| 		local area_count = area_list.len() | |||
| 		// No areas found? Uh oh | |||
| 		if (area_count == 0) | |||
| 			return false | |||
| 		// First point is simply our current position | |||
| 		path.append(PathPoint(area_start, pos_start, NUM_TRAVERSE_TYPES)) | |||
| 		// Now build points using the list of areas, which the bot will then target | |||
| 		// The areas are built from the end to the start so we need a reversed iteration to build the path points | |||
| 		for (local i = area_count - 1; i >= 0; i--) | |||
| 		{ | |||
| 			local area = area_list["area" + i] | |||
| 			path.append(PathPoint(area, area.GetCenter(), area.GetParentHow())) | |||
| 		} | |||
| 		// Now compute accurate path points, using adjacent points + direction data from nav | |||
| 		path_count = path.len() | |||
| 		for (local i = 1; i < path_count; i++) | |||
| 		{ | |||
| 			local point_from = path[i - 1] | |||
| 			local point_to = path[i] | |||
| 			// Computes closest point within the "portal" between adjacent areas | |||
| 			point_to.pos = point_from.area.ComputeClosestPointInPortal(point_to.area, point_to.how, point_from.pos) | |||
| 		} | |||
| 		// Add a final point so the bot can precisely move towards the end point when it reaches the final area | |||
| 		path.append(PathPoint(area_end, pos_end, NUM_TRAVERSE_TYPES)) | |||
| 		path_count++ | |||
| 		return true | |||
| 	} | |||
| 	function AdvancePath() | |||
| 	{ | |||
| 		// Check for valid path first | |||
| 		if (path_count == 0) | |||
| 			return false | |||
| 		// If we're close enough to the target stop to not push our target entity | |||
| 		if (path_follow_ent && path_follow_ent.IsValid() && | |||
| 			(path_target_pos - bot_pos).Length() < path_closest_distance) | |||
| 		{ | |||
| 			ResetPath() | |||
| 			return false | |||
| 		} | |||
| 		// Are we close enough to the path point to consider it as 'reached'? | |||
| 		if ((move_pos - bot_pos).Length2D() < path_reach_dist) | |||
| 		{ | |||
| 			// Start moving to the next point | |||
| 			path_index++ | |||
| 			if (path_index >= path_count) | |||
| 			{ | |||
| 				// End of the line! | |||
| 				ResetPath() | |||
| 				return false | |||
| 			} | |||
| 		} | |||
| 		return true | |||
| 	} | |||
| 	function ResetPath() | |||
| 	{ | |||
| 		area_list.clear() | |||
| 		path.clear() | |||
| 		path_count = 0 | |||
| 		path_index = 0 | |||
| 	} | |||
| 	function Move() | |||
| 	{ | |||
| 		// Recompute path to our target if present | |||
| 		if (path_follow_ent && path_follow_ent.IsValid()) | |||
| 		{ | |||
| 			// Is it time to re-compute the path? | |||
| 			local time = Time() | |||
| 			if (path_update_time_next < time) | |||
| 			{ | |||
| 				// Check if target has moved far away enough | |||
| 				local follow_ent_pos = path_follow_ent.GetOrigin() | |||
| 				if ((path_target_pos - follow_ent_pos).Length() > path_follow_ent_dist && | |||
| 					(bot_pos - follow_ent_pos).Length() > path_closest_distance) | |||
| 				{ | |||
| 					if (UpdatePath()) | |||
| 						SetDirection(path[0].pos) | |||
| 					// Don't recompute again for a moment | |||
| 					path_update_time_next = time + path_update_time_delay | |||
| 				} | |||
| 			} | |||
| 		} | |||
| 		// Check and advance up our path | |||
| 		if (!AdvancePath()) | |||
| 		{ | |||
| 			return false | |||
| 		} | |||
| 		// Current path point | |||
| 		local target = path[path_index].pos | |||
| 		// Get 2D forward motion vector | |||
| 		local motion_fwd = target - bot_pos | |||
| 		motion_fwd.z = 0.0 | |||
| 		motion_fwd.Norm() | |||
| 		// Get 2D lateral motion vector, which is perpendicular to the forward motion vector, going left | |||
| 		local motion_lat = Vector(-motion_fwd.y, motion_fwd.x) | |||
| 		// Get the ground normal and compute the 3D motion vectors | |||
| 		local normal = locomotion.GetGroundNormal() | |||
| 		motion_fwd = motion_lat.Cross(normal) | |||
| 		motion_lat = motion_fwd.Cross(normal) | |||
| 		// The position traces will rely on, shift 18 units upwards to avoid hitting the obstacle you can climb onto | |||
| 		local pos = Vector(bot_pos.x, bot_pos.y, bot_pos.z + 18.0) | |||
| 		// The length vector of the traces going forward | |||
| 		local fwd_vec = motion_fwd * 60.0 | |||
| 		// The offset to the left/right from the original position | |||
| 		local lat_vec = motion_lat * 20.0 | |||
| 		// Trace slightly to the left of the bot, going forward | |||
| 		local left_pos = pos + lat_vec | |||
| 		local left_trace = | |||
| 		{ | |||
| 			start = left_pos | |||
| 			end = left_pos + fwd_vec | |||
| 			mask = MASK_SOLID | |||
| 			ignore = bot | |||
| 		} | |||
| 		TraceLineEx(left_trace) | |||
| 		// Trace slightly to the right of the bot, going forward | |||
| 		local right_pos = pos - lat_vec | |||
| 		local right_trace = | |||
| 		{ | |||
| 			start = right_pos | |||
| 			end = right_pos + fwd_vec | |||
| 			mask = MASK_SOLID | |||
| 			ignore = bot | |||
| 		} | |||
| 		TraceLineEx(right_trace) | |||
| 		// If left trace hit something, but right didn't - move to the right | |||
| 		// If right trace hit something, but left didn't - move to the left | |||
| 		// Otherwise if 0 or 2 traces hit something, leave the target position as it was | |||
| 		if (left_trace.hit) | |||
| 		{ | |||
| 			if (!right_trace.hit) | |||
| 			{ | |||
| 				target -= motion_lat * move_speed | |||
| 			} | |||
| 		} | |||
| 		else if (right_trace.hit) | |||
| 		{ | |||
| 			target += motion_lat * move_speed | |||
| 		} | |||
| 		SetDirection(target) | |||
| 		// Set our new position | |||
| 		// Velocity is calculated from direction times speed, and converted from per-second to per-tick time | |||
| 		bot.SetAbsOrigin(bot_pos + (move_dir * move_speed * FrameTime())) | |||
| 		// Visualize current path in debug mode | |||
| 		if (debug) | |||
| 		{ | |||
| 			// Stay around for 1 tick | |||
| 			// Debugoverlays are created on 1st tick but start rendering on 2nd tick, hence this must be doubled | |||
| 			local frame_time = FrameTime() * 2.0 | |||
| 			// Draw connected path points | |||
| 			local path_start_index = path_index | |||
| 			if (path_start_index == 0) | |||
| 				path_start_index++ | |||
| 			for (local i = path_start_index; i < path_count; i++) | |||
| 			{ | |||
| 				DebugDrawLine(path[i - 1].pos, path[i].pos, 0, 255, 0, true, frame_time) | |||
| 			} | |||
| 			// Draw areas from built path | |||
| 			foreach (name, area in area_list) | |||
| 			{ | |||
| 				area.DebugDrawFilled(255, 0, 0, 30, frame_time, true, 0.0) | |||
| 				DebugDrawText(area.GetCenter(), name, false, frame_time) | |||
| 			} | |||
| 			// Left trace | |||
| 			DebugDrawLine(left_trace.start, left_trace.end, left_trace.hit ? 0 : 255, left_trace.hit ? 255 : 0, 0, true, frame_time) | |||
| 			// Right trace | |||
| 			DebugDrawLine(right_trace.start, right_trace.end, right_trace.hit ? 0 : 255, right_trace.hit ? 255 : 0, 0, true, frame_time) | |||
| 		} | |||
| 		return true | |||
| 	} | |||
| 	function Update() | |||
| 	{ | |||
| 		bot_pos = bot.GetOrigin() | |||
| 		// Try moving | |||
| 		if (Move()) | |||
| 		{ | |||
| 			// Moving, set the run animation | |||
| 			if (bot.GetSequence() != seq_run) | |||
| 			{ | |||
| 				bot.SetSequence(seq_run) | |||
| 				bot.SetPoseParameter(pose_move_x, 1.0) // Set the move_x pose to max weight | |||
| 			} | |||
| 		} | |||
| 		else | |||
| 		{ | |||
| 			// Not moving, set the idle animation | |||
| 			if (bot.GetSequence() != seq_idle) | |||
| 			{ | |||
| 				bot.SetSequence(seq_idle) | |||
| 				bot.SetPoseParameter(pose_move_x, 0.0) // Clear the move_x pose | |||
| 			} | |||
| 			// If the bot is standing still, look at the target instead of the path points | |||
| 			if (path_follow_ent && path_follow_ent.IsValid()) | |||
| 				SetDirection(path_follow_ent.GetOrigin()) | |||
| 		} | |||
| 		// Rotating the bot | |||
| 		// Approach new desired angle but only on the Y axis | |||
| 		local bot_ang = bot.GetAbsAngles() | |||
| 		bot_ang.y = ApproachAngle(move_ang.y, bot_ang.y, turn_rate) | |||
| 		// Set our new angles | |||
| 		bot.SetAbsAngles(bot_ang) | |||
| 		// Replay animation if it has finished | |||
| 		if (bot.GetCycle() > 0.99) | |||
| 			bot.SetCycle(0.0) | |||
| 		// Run animations | |||
| 		bot.StudioFrameAdvance() | |||
| 		bot.DispatchAnimEvents(bot) | |||
| 		return -1 // Think again next frame | |||
| 	} | |||
| 	function OnKilled() | |||
| 	{ | |||
| 		// Change life state to "dying" | |||
| 		// The bot won't take any more damage, and sentries will stop targeting it | |||
| 		NetProps.SetPropInt(bot, "m_lifeState", LIFE_DYING) | |||
| 		// For this example, turn into a ragdoll with the saved damage force | |||
| 		bot.BecomeRagdollOnClient(damage_force) | |||
| 		// Stop pathfinding | |||
| 		AddThinkToEnt(bot, null) | |||
| 		// Custom death behavior can be added here | |||
| 	} | |||
| 	bot = null						// The bot entity we belong to | |||
| 	locomotion = null				// Bot's locomotion interface | |||
| 	health = null					// Manual track of health to prevent default base_boss death behavior | |||
| 	move_speed = null				// How fast to move | |||
| 	turn_rate = null				// How fast to turn | |||
| 	search_dist_z = null			// Maximum distance to look for a nav area downwards | |||
| 	search_dist_nearest = null 		// Maximum distance to look for any nearby nav area | |||
| 	bot_pos = null					// Origin of the bot | |||
| 	move_pos = null					// Current target destination (path point) | |||
| 	move_dir = null					// Current move direction | |||
| 	move_ang = null					// Current move direction in angle form | |||
| 	path = null						// List of BotPathPoints | |||
| 	path_index = null				// Current path point bot is at, -1 if none | |||
| 	path_count = null				// Number of path points | |||
| 	path_follow_ent = null			// What entity to move towards | |||
| 	path_follow_ent_dist = null		// Maximum distance after which the path is recomputed | |||
| 									// if target entity's current position is too far from our target position | |||
| 	path_closest_distance = null	// The closest the bot can get to an entity before stopping | |||
| 									// required to not push the entity when we get too close | |||
| 	path_reach_dist = null			// Distance to a path point to be considered as 'reached' | |||
| 	path_target_pos = null			// Position where bot wants to navigate to | |||
| 	path_update_time_next = null	// Timer for when to update path again | |||
| 	path_update_time_delay = null   // Seconds to wait before trying to attempt to update path again | |||
| 	area_list = null				// List of areas built in path | |||
| 	seq_idle = null					// Animation to use when idle | |||
| 	seq_run = null					// Animation to use when running | |||
| 	pose_move_x = null				// Pose parameter to set for running animation | |||
| 	damage_force = null				// Damage force from the bot's last OnTakeDamage event | |||
| 	debug = null					// When true, debug visualization is enabled | |||
| } | |||
| ::BotCreate <- function() | |||
| { | |||
| 	// Find point where player is looking | |||
| 	local player = GetListenServerHost() | |||
| 	local eye_pos = player.EyePosition() | |||
| 	local trace = | |||
| 	{ | |||
| 		start = eye_pos, | |||
| 		end = eye_pos + (player.EyeAngles().Forward() * 32768.0), | |||
| 		ignore = player | |||
| 	} | |||
| 	if (!TraceLineEx(trace)) | |||
| 	{ | |||
| 		printl("Invalid bot spawn location") | |||
| 		return null | |||
| 	} | |||
| 	// Spawn bot at the end point and start following the player | |||
| 	return Bot(trace.pos, player) | |||
| } | |||
| // event listener (see Listening for Events example) | |||
| CollectEventsInScope | |||
| ({ | |||
| 	function OnScriptHook_OnTakeDamage(params) | |||
| 	{ | |||
| 		local victim = params.const_entity | |||
| 		local scope = victim.GetScriptScope() | |||
| 		if (victim.IsPlayer() && "bot_brain" in params.inflictor.GetScriptScope() | |||
| 			&& params.damage_type == DMG_CRUSH) | |||
| 		{ | |||
| 			// Don't crush the player if a bot pushes them into a wall | |||
| 			params.damage = 0 | |||
| 		} | |||
| 		if ("bot_brain" in scope) | |||
| 		{ | |||
| 			// Save the damage force into the bot's data | |||
| 			scope.bot_brain.damage_force = params.damage_force | |||
| 		} | |||
| 	} | |||
| 	function OnGameEvent_npc_hurt(params) | |||
| 	{ | |||
| 		local victim = EntIndexToHScript(params.entindex) | |||
| 		local scope = victim.GetScriptScope() | |||
| 		if ("bot_brain" in scope) | |||
| 		{ | |||
| 			// Substract the damage dealt from our manual health track | |||
| 			scope.bot_brain.health -= params.damageamount | |||
| 			// Check if a bot is about to die | |||
| 			if (scope.bot_brain.health <= 0) | |||
| 			{ | |||
| 				// Run the bot's OnKilled function | |||
| 				scope.bot_brain.OnKilled() | |||
| 			} | |||
| 		} | |||
| 	} | |||
| }) | |||
| ::LastBot <- BotCreate() | |||
| // to dynamically change the bot's destination you can write the following in the console | |||
| // script LastBot.SetDestination(Vector(100.0, 100.0, 0.0)) | |||
| // script LastBot.SetDestination(GetListenServerHost()) | |||
| // script LastBot.SetDestination(GetListenServerHost().GetOrigin()) | |||
| // etc | |||
| </syntaxhighlight> | |||
| = See also = | |||
| * {{tf2}} [[Team Fortress 2/Scripting/Script Functions|List of Team Fortress 2 Script Functions]] | |||
| * [[List of Script Libraries]], these can also be useful as examples | |||
| * [https://developer.valvesoftware.com/wiki/Team_Fortress_2/Scripting/Community_Contributions (WIP) Community Contributions], Contributions submitted by TF2Maps | |||
| [[Category:VScript examples]] | |||
| [[Category:Scripting]] | |||
| [[Category:Team Fortress 2]] | [[Category:Team Fortress 2]] | ||
Latest revision as of 15:57, 26 August 2025
 This page contains examples of vscripts specifically for Team Fortress 2.
 This page contains examples of vscripts specifically for Team Fortress 2.
Generic examples that work on all VScript games can be found on the SDK 2013 page.
Iterating Through Player's Cosmetics
Cosmetics are called "wearables" internally.
 Note:This can also be used for iterating over weapons, not just cosmetics
Note:This can also be used for iterating over weapons, not just cosmeticsfor (local wearable = player.FirstMoveChild(); wearable != null; wearable = wearable.NextMovePeer())
{
	if (wearable.GetClassname() != "tf_wearable")
		continue
	printl(wearable)
}
Swapping the !activator's Team
Whenever a script's code gets executed by inputs (e.g.,RunScriptCode/CallScriptFunctionon an entity with an attached entity script), the activator and caller variables will be set to the handles of the input's !activator and !caller respectively, allowing you to access them in code.
We can use that to easily do something to players that walk in a specific trigger, for example.
As for swapping the !activator's team, we need to do both change their team based on which team they are, and then also change the their cosmetic items' team to the their's new team.
First we need to figure on which team the !activator is. For that, we can use the GetTeam() method on the !activator (activator.GetTeam()) to get the number index of their team.
You can either use an if function to compare the returned value with the desired team index, or store it in a variable to use it later. The latter in this case may be better to reduce the length of the script.
For reference: 0 is Unassigned (no team yet), 1 is Spectator, 2 is Red, 3 is Blu.
To change the !activator's team, we should use the ForceChangeTeam(Int : Team index, Bool : Kill player if game is in Highlander mode + remove their dominations) method on them via activator.ForceChangeTeam(...).
To change their cosmetic items' color, we need to iterate over every tf_wearable entity, and then change its team via SetTeam(Int : Team index) if they are worn by the !activator.
- To iterate, we need to specify a null variable (e.g. local cosmetic = null), and then pass it into the following while loop:
local cosmetic = null // Assign the "cosmetic" variable to null, will be assigned new values when going over cosmetic items
while (cosmetic = Entities.FindByClassname(cosmetic, "tf_wearable"))
{
	// Do things to individual tf_wearables entities here, the "cosmetic" variable is set to the current tf_wearable entity we are iterating through
}
- To check if the cosmetic item belongs to the !activator, we can simply compare the value returned by the GetOwner()method when we use it on the tf_wearable (e.g.cosmetic.GetOwner()) against the !activator. Example code:
if (cosmetic.GetOwner() == activator)
{
	// Do things if the cosmetic's wearer is the !activator
}
- To change the cosmetic's team, we should use the SetTeam(Int : Team index)method on the cosmetic item entity.
Full code with comments:
const TEAM_UNASSIGNED = 0
const TEAM_SPECTATOR = 1
const TF_TEAM_RED = 2
const TF_TEAM_BLUE = 3
function ActivatorSwapTeam() // Call this in map via RunScriptCode > ActivatorSwapTeam() or CallScriptFunction > ActivatorSwapTeam on a logic_script that has an Entity Script with this function
{
	// The following snippet checks if an !activator has been specified and if they are a player
	// If either question's answer is no, then don't execute the rest of the function
	if (activator == null || activator.IsPlayer() == false)
		return
	// The following snippet compares the !activator's team number (Ranging from 0 to 3) to the ones used by Unassigned (0) and Spectator (1)
	// If they match with either Unassigned or Spectator, we don't execute the rest of the function
	// Used to ignore any potentional spectator !activators
	local current_team = activator.GetTeam()
	if (current_team == TEAM_UNASSIGNED || current_team == TEAM_SPECTATOR)
		return
	// The following snippet specifies a local new_team variable, and then we set it to a team number based off the !activator's current team number
	local new_team
	if (current_team == TF_TEAM_RED) // Checks if the !activator's team number is 2 (Red), and sets the new_team variable to 3 (Blu)
		new_team = TF_TEAM_BLUE
	else // If the !activator's team number is not 2 (Red), sets the new_team variable to 2 (Red) instead
		new_team = TF_TEAM_RED
	// The following snippet calls the ForceChangeTeam method on the !activator
	// First parameter: Team number to switch to
	// Second parameter: If false, the game will reset the player's dominations and nemesises, and kill them if mp_highlander is on
	activator.ForceChangeTeam(new_team, true)
	local cosmetic = null // Assign the "cosmetic" variable to null, will be assigned new values when going over cosmetic items
	// The following snippet will go over every cosmetic item currently present, and will change its colours to the appropriate team if they are the !activator's
	while (cosmetic = Entities.FindByClassname(cosmetic, "tf_wearable")) // Goes over every cosmetic item, executing code below
	{
		if (cosmetic.GetOwner() == activator) // Checks if the currently iterated cosmetic item's wearer is the !activator
		{
			cosmetic.SetTeam(new_team) // Sets the team of the cosmetic item to the new team number that we stored in new_team
		}
	}
}
Adding attributes to player on spawn
Intuitively, applying attributes to the player on the player_spawn event should work. Unfortunately this doesn't work in practice, as the game clears all custom attributes immediately after this event is fired. As a workaround, the attributes can be postponed to be added at the end of the frame, as shown below. The team check is requied because player_spawn runs once when a player is put on the unassigned team.
 Note:This assumes event callbacks are setup already, like in the example above.
Note:This assumes event callbacks are setup already, like in the example above.const TEAM_UNASSIGNED = 0
::PostPlayerSpawn <- function()
{
	// "self" is the player entity here
	self.AddCustomAttribute("no jump", 1, -1)
}
function OnGameEvent_player_spawn(params)
{
	local player = GetPlayerFromUserID(params.userid)
	if (params.team != TEAM_UNASSIGNED)
	{
		EntFireByHandle(player, "CallScriptFunction", "PostPlayerSpawn", 0, null, null)
	}
}
Setting up a Boss Health Bar
The boss bar that appears while any bosses are active (such as MONOCULUS!) is handled by the monster_resource entity, which conveniently exists in the map normally.
You can do Entities.FindByClassname(null, "monster_resource") to get the monster_resource entity most of the time, but it'd be convenient to store it into a variable instead.
In order to modify the health bar's percentage, it'd be useful to first know about NetProps:
- NetProps are network properties of an entity, which are server-side only.
- They can be accessed and changed by the NetProps class methods.
The monster_resource entity has a m_iBossHealthPercentageByte NetProp, which determines the percentage state of the health bar based on a byte value - its value must be between 0 and 255, where 0 is 0%, and 255 is 100%
The following code will add a health bar with 25% health after executed.
local health_bar = Entities.FindByClassname(null, "monster_resource") // Get the health bar entity.
if (health_bar) // Check if the health bar entity exists, just in case to prevent errors
{
	// The following line will update the health bar's percentage to 25% by changing its NetProp.
	// Do note that because it is a byte (0 - 255), we need to multiply our percentage by 255.
	NetProps.SetPropInt(health_bar, "m_iBossHealthPercentageByte", 0.25 * 255)
}
Giving weapons, cosmetics and taunts
All weapons, cosmetics, taunts, economy items etc are stored within items_game.txt. This file is found within the game's directory /tf/scripts/items/.
Giving a weapon
See this page to find corresponding classname and item_ids for each weapon.
 Warning:This will cause a client crash when giving an Engineer toolbox (tf_weapon_builder with item_id 28). To fix this, you must set the
Warning:This will cause a client crash when giving an Engineer toolbox (tf_weapon_builder with item_id 28). To fix this, you must set the m_iObjectType and m_iObjectMode netprops to 0 before equipping. You also need to set the m_aBuildableObjectTypes netprop array to allow objects to be built.const MAX_WEAPONS = 8
::GivePlayerWeapon <- function(player, classname, item_id)
{
	local weapon = Entities.CreateByClassname(classname)
	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", item_id)
	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
	NetProps.SetPropBool(weapon, "m_bValidatedAttachedEntity", true)
	weapon.SetTeam(player.GetTeam())
	weapon.DispatchSpawn()
	// remove existing weapon in same slot
	for (local i = 0; i < MAX_WEAPONS; i++)
	{
		local held_weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
		if (held_weapon == null)
			continue
		if (held_weapon.GetSlot() != weapon.GetSlot())
			continue
		held_weapon.Destroy()
		NetProps.SetPropEntityArray(player, "m_hMyWeapons", null, i)
		break
	}
	player.Weapon_Equip(weapon)
	player.Weapon_Switch(weapon)
	return weapon
}
GivePlayerWeapon(GetListenServerHost(), "tf_weapon_smg", 16)
Giving a cosmetic
Spawns a tf_wearable entity made by the B.A.S.E. Jumper item. This tf_wearable allows for any model to appear on the player's ragdoll upon death. (Player ragdolls are client-side and cannot be accessed by VScript, so there is no other way to parent anything to the ragdoll unless you use this kind of tf_wearable)
Optionally, this method can be used to update the player's bodygroups, which cannot be altered with SetBodygroup(). A fix is to give an econ item that removes a bodygroup, set the model to "models/empty.mdl", then send the Game Event post_inventory_application to the player to refresh the bodygroups of the econ items.
 Warning:These spawned items permanently stay on the player entity, so they must be manually removed. A solution is putting the item's handles into the player's scope, then when the player respawns, delete the items using a loop. (Example below)
Warning:These spawned items permanently stay on the player entity, so they must be manually removed. A solution is putting the item's handles into the player's scope, then when the player respawns, delete the items using a loop. (Example below) Note:You must use the function
Note:You must use the function GetPlayerUserID() from an example here.::GivePlayerCosmetic <- function(player, item_id, model_path = null)
{
	local weapon = Entities.CreateByClassname("tf_weapon_parachute")
	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 1101)
	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
	weapon.SetTeam(player.GetTeam())
	weapon.DispatchSpawn()
	player.Weapon_Equip(weapon)
	local wearable = NetProps.GetPropEntity(weapon, "m_hExtraWearable")
	weapon.Kill()
	NetProps.SetPropInt(wearable, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", item_id)
	NetProps.SetPropBool(wearable, "m_AttributeManager.m_Item.m_bInitialized", true)
	NetProps.SetPropBool(wearable, "m_bValidatedAttachedEntity", true)
	wearable.DispatchSpawn()
	// (optional) Set the model to something new. (Obeys econ's ragdoll physics when ragdolling as well)
	if (model_path)
		wearable.SetModelSimple(model_path)
	// (optional) recalculates bodygroups on the player
	SendGlobalGameEvent("post_inventory_application", { userid = GetPlayerUserID(player) })
	// (optional) if one wants to delete the item entity, collect them within the player's scope, then send Kill() to the entities within the scope.
	player.ValidateScriptScope()
	local player_scope = player.GetScriptScope()
	if (!("wearables" in player_scope))
		player_scope.wearables <- []
	player_scope.wearables.append(wearable)
	return wearable
}
 Tip:For vision restricted items (halloween hats, romevision), use the model_path argument and use a random dummy item id instead.  For example:
Tip:For vision restricted items (halloween hats, romevision), use the model_path argument and use a random dummy item id instead.  For example:
GivePlayerCosmetic(GetListenServerHost(), 9911, "models/workshop/player/items/soldier/tw_soldierbot_helmet/tw_soldierbot_helmet.mdl")
to equip the romevision soldier bot helmet.
Giving a taunt
There is no known way to give taunts directly into the inventory, but all taunts can be forced using a workaround as shown below.
For a list of taunts, search their ID in items_games.txt. The Alliedmodders page also has an incomplete list.
Example usage (conga): ForceTaunt(GetListenServerHost(), 1118)
const TF_COND_TAUNTING = 7
function ForceTaunt(player, taunt_id)
{
	local weapon = Entities.CreateByClassname("tf_weapon_bat")
	local active_weapon = player.GetActiveWeapon()
	player.StopTaunt(true) // both are needed to fully clear the taunt
	player.RemoveCond(TF_COND_TAUNTING)
	weapon.DispatchSpawn()
	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", taunt_id)
	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
	NetProps.SetPropBool(weapon, "m_bForcePurgeFixedupStrings", true)
	NetProps.SetPropEntity(player, "m_hActiveWeapon", weapon)
	NetProps.SetPropInt(player, "m_iFOV", 0) // fix sniper rifles
	player.HandleTauntCommand(0)
	NetProps.SetPropEntity(player, "m_hActiveWeapon", active_weapon)
	weapon.Kill()
}
Creating Mannpower powerups
Creating any type of Mannpower powerup other than Strength is usually not possible manually. However, players are able to drop powerups, which can therefore be manipulated to drop any kind of powerup. The following example abstracts this to allow creating a powerup with a given position, angles, velocity, team and type. The powerup can also be set not to "re-position" (blink and disappear) after time has passed, unlike usual dropped powerups.
Example usage:
CreateRune(Vector(0, 0, -100), QAngle(0, 0, 0), Vector(-500, 0, 0), Constants.ETFTeam.TEAM_ANY, ERuneTypes.RUNE_VAMPIRE, false)
 Note:This code converts existing powerup classnames from
Note:This code converts existing powerup classnames from item_powerup_rune to zitem_powerup_rune for speed.enum ERuneTypes
{
	RUNE_STRENGTH,
	RUNE_HASTE,
	RUNE_REGEN,
	RUNE_RESIST,
	RUNE_VAMPIRE,
	RUNE_REFLECT,
	RUNE_PRECISION,
	RUNE_AGILITY,
	RUNE_KNOCKOUT,
	RUNE_KING,
	RUNE_PLAGUE,
	RUNE_SUPERNOVA
}
::RuneTypeToCond <-
[
	Constants.ETFCond.TF_COND_RUNE_STRENGTH,
	Constants.ETFCond.TF_COND_RUNE_HASTE,
	Constants.ETFCond.TF_COND_RUNE_REGEN,
	Constants.ETFCond.TF_COND_RUNE_RESIST,
	Constants.ETFCond.TF_COND_RUNE_VAMPIRE,
	Constants.ETFCond.TF_COND_RUNE_REFLECT,
	Constants.ETFCond.TF_COND_RUNE_PRECISION,
	Constants.ETFCond.TF_COND_RUNE_AGILITY,
	Constants.ETFCond.TF_COND_RUNE_KNOCKOUT,
	Constants.ETFCond.TF_COND_RUNE_KING,
	Constants.ETFCond.TF_COND_RUNE_PLAGUE,
	Constants.ETFCond.TF_COND_RUNE_SUPERNOVA
]
::MaxPlayers <- MaxClients().tointeger()
::CreateRune <- function(origin, angles, velocity, team, type, reposition)
{
	// select random player to create a rune from
	// prioritize players with no rune, as stripping the rune temporarily can have side effects
	local player, fallback
	for (local i = 1; i <= MaxPlayers; i++)
	{
		player = PlayerInstanceFromIndex(i)
		if (player)
		{
			if (player.IsCarryingRune())
			{
				fallback = player
				player = null
				continue
			}
			break
		}
	}
	if (!player)
	{
		if (!fallback)
			return null
		player = fallback
	}
	// to detect the rune that was spawned, every existing rune must be hidden
	for (local rune; rune = Entities.FindByClassname(rune, "item_powerup_rune");)
		rune.KeyValueFromString("classname", "zitem_powerup_rune")
	local cond = RuneTypeToCond[type]
	// if player already has a rune, temporarily strip it
	local player_cond, player_cond_duration
	if (player.IsCarryingRune())
	{
		foreach (cond in RuneTypeToCond)
		{
			player_cond_duration = player.GetCondDuration(cond)
			if (player_cond_duration != 0.0)
			{
				player.RemoveCond(cond)
				player_cond = cond
				break
			}
		}
	}
	local cond_prop = "m_Shared." + (cond >= 96 ? "m_nPlayerCondEx3" : "m_nPlayerCondEx2")
	local cond_bits = NetProps.GetPropInt(player, cond_prop)
	NetProps.SetPropInt(player, cond_prop, cond_bits | (1 << (cond % 32)))
	player.DropRune(false, team)
	NetProps.SetPropInt(player, cond_prop, cond_bits)
	// give original rune back
	if (player_cond)
		player.AddCondEx(player_cond, player_cond_duration, null)
	local rune = Entities.FindByClassname(null, "item_powerup_rune")
	if (!rune)
		return null
	rune.Teleport(true, origin, true, angles, true, velocity)
	if (!reposition)
	{
		// prevents rune from blinking after 30 or 60 seconds
		// and teleporting to a spawnpoint if one exists
		rune.AddEFlags(Constants.FEntityEFlags.EFL_NO_THINK_FUNCTION)
		rune.AddSolidFlags(Constants.FSolid.FSOLID_TRIGGER)
	}
	return rune
}
Detecting melee smacks / creating projectiles with damage
This example demonstrates how to reliably detect when a melee has fired, by abusing a quirk in one of the melee's internal variables. When a Heavy attacks with their fists, they will emit a fireball like from the Dragon's Fury. Therefore this example also demonstrates how to create projectiles that deal damage which can be problematic by usual means.
const ID_DRAGONS_FURY = 1178
const TF_AMMO_PRIMARY = 1
const TF_CLASS_HEAVYWEAPONS = 6
const MAX_WEAPONS = 8
if (!("FireballMaker" in getroottable()) || !FireballMaker.IsValid())
{
	// spawn a fake dragon's fury to emit the fireballs
	FireballMaker <- Entities.CreateByClassname("tf_weapon_rocketlauncher_fireball")
	NetProps.SetPropInt(FireballMaker, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", ID_DRAGONS_FURY)
	NetProps.SetPropBool(FireballMaker, "m_AttributeManager.m_Item.m_bInitialized", true)
	FireballMaker.DispatchSpawn()
	FireballMaker.SetClip1(-1)
}
function CheckMeleeSmack()
{
	local owner = self.GetOwner()
	// when melee smacks, m_iNextMeleeCrit is 0
	if (NetProps.GetPropInt(owner, "m_Shared.m_iNextMeleeCrit") == 0)
	{
		// when switching away from melee, m_iNextMeleeCrit will also be 0 so check for that case
		if (owner.GetActiveWeapon() == self)
		{
			// preserve old charge meter and ammo count
			local charge = NetProps.GetPropFloat(owner, "m_Shared.m_flItemChargeMeter")
			local ammo = NetProps.GetPropIntArray(owner, "m_iAmmo", TF_AMMO_PRIMARY)
			// set up stuff needed to ensure the weapon always fires
			NetProps.SetPropIntArray(owner, "m_iAmmo", 99, TF_AMMO_PRIMARY)
			NetProps.SetPropFloat(owner, "m_Shared.m_flItemChargeMeter", 100.0)
			NetProps.SetPropBool(owner, "m_bLagCompensation", false)
			NetProps.SetPropFloat(FireballMaker, "m_flNextPrimaryAttack", 0)
			NetProps.SetPropEntity(FireballMaker, "m_hOwner", owner)
			FireballMaker.PrimaryAttack()
			// revert changes
			NetProps.SetPropBool(owner, "m_bLagCompensation", true)
			NetProps.SetPropIntArray(owner, "m_iAmmo", ammo, TF_AMMO_PRIMARY)
			NetProps.SetPropFloat(owner, "m_Shared.m_flItemChargeMeter", charge)
		}
		// continue smack detection
		NetProps.SetPropInt(owner, "m_Shared.m_iNextMeleeCrit", -2)
	}
	return -1
}
// event listener (see Listening for Events example)
CollectEventsInScope
({
	OnGameEvent_player_spawn = function(params)
	{
		local player = GetPlayerFromUserID(params.userid)
		local player_class = params["class"]
		if (player_class != TF_CLASS_HEAVYWEAPONS)
			return
		for (local i = 0; i < MAX_WEAPONS; i++)
		{
			local weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
			if (weapon == null || !weapon.IsMeleeWeapon())
				continue
			// this kicks off melee smack detection logic
			NetProps.SetPropInt(player, "m_Shared.m_iNextMeleeCrit", -2)
			AddThinkToEnt(weapon, "CheckMeleeSmack")
			break
		}
	}
})
Detecting weapon firing
Similar to above example, on all weapons except melee there is another variable that can be tracked. This example pushes the player back when they fire their weapon.
const MAX_WEAPONS = 8
function CheckWeaponFire()
{
	local fire_time = NetProps.GetPropFloat(self, "m_flLastFireTime")
	if (fire_time > last_fire_time)
	{
		printf("%f %s : Fired\n", Time(), self.GetClassname())
		local owner = self.GetOwner()
		if (owner)
		{
			owner.SetAbsVelocity(owner.GetAbsVelocity() - owner.EyeAngles().Forward() * 800.0)
		}
		last_fire_time = fire_time
	}
	return -1
}
// event listener (see Listening for Events example)
CollectEventsInScope
({
	OnGameEvent_player_spawn = function(params)
	{
		local player = GetPlayerFromUserID(params.userid)
		if (!player)
			return
		for (local i = 0; i < MAX_WEAPONS; i++)
		{
			local weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
			if (weapon == null || weapon.IsMeleeWeapon())
				continue
			weapon.ValidateScriptScope()
			weapon.GetScriptScope().last_fire_time <- 0.0
			weapon.GetScriptScope().CheckWeaponFire <- CheckWeaponFire
			AddThinkToEnt(weapon, "CheckWeaponFire")
		}
	}
})
Disabling Medic health regen
This effectively cancels out the passive health regeneration from a medic.
function OnGameEvent_player_healed(params)
{
	if (params.patient == params.healer)
	{
		local player = GetPlayerFromUserID(params.patient)
		if (player.GetPlayerClass() == Constants.ETFClass.TF_CLASS_MEDIC
			&& NetProps.GetPropInt(player, "m_bitsDamageType") == 32)
		{
			player.SetHealth(player.GetHealth() - params.amount)
		}
	}
}
Special death effects on triggers
Weapons that have special death effects (such as disintegration from Phlogistinator, or the ice statue from Spy-cicle) can be applied to triggers via two methods:
Using the TakeDamageCustom function
This method will work for most listed custom damage types, e.g. decapitation or Cow Mangler's dissolve effect. Otherwise, the other method must be used.
Add the following output to a trigger_hurt. Replace 9999 with the desired damage and TF_DMG_CUSTOM part with the desired effect.
OnStartTouch | !activator | RunScriptCode | self.TakeDamageCustom(activator, activator, null, Vector(), Vector(), 9999, 0, Constants.ETFDmgCustom.TF_DMG_CUSTOM_PLASMA)Using a dummy weapon
Some death effects are not available as a custom damage type, but they can be applied by using a spoofed weapon as the damage owner.
The example below shows how to create a trigger that kills players with a disintegration effect when touched. This script can be assigned to any trigger entity. Note that if the trigger is not a trigger_hurt, Engineer buildings will be possible to place inside. This can be workarounded by placing a func_nobuild in it's place as well.
const ID_SUN_ON_A_STICK = 349
// Ensure only one of this entity is ever spawned
if (!("disintegrate_proxy_weapon" in getroottable()))
{
	::disintegrate_proxy_weapon <- null
	::disintegrate_immune_conds <-
	[
		Constants.ETFCond.TF_COND_INVULNERABLE,
		Constants.ETFCond.TF_COND_PHASE,
		Constants.ETFCond.TF_COND_INVULNERABLE_HIDE_UNLESS_DAMAGED,
		Constants.ETFCond.TF_COND_INVULNERABLE_USER_BUFF,
		Constants.ETFCond.TF_COND_INVULNERABLE_CARD_EFFECT,
	]
}
// Create the fake weapon if one doesn't already exist
if (!disintegrate_proxy_weapon || !disintegrate_proxy_weapon.IsValid())
{
	disintegrate_proxy_weapon = Entities.CreateByClassname("tf_weapon_bat")
	NetProps.SetPropInt(disintegrate_proxy_weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", ID_SUN_ON_A_STICK)
	NetProps.SetPropBool(disintegrate_proxy_weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
	disintegrate_proxy_weapon.DispatchSpawn()
	disintegrate_proxy_weapon.DisableDraw()
	// Add the attribute that creates disintegration
	disintegrate_proxy_weapon.AddAttribute("ragdolls become ash", 1, -1)
}
function Precache()
{
	// Add an output to deal damage when the trigger is touched
	self.ConnectOutput("OnStartTouch", "Disintegrate")
}
function Disintegrate()
{
	// Remove conditions that give immunity to damage
	foreach (cond in disintegrate_immune_conds)
		activator.RemoveCondEx(cond, true)
	// Set any owner on the weapon to prevent a crash
	NetProps.SetPropEntity(disintegrate_proxy_weapon, "m_hOwner", activator)
	// Deal the damage with the weapon
	activator.TakeDamageCustom(self, activator, disintegrate_proxy_weapon,
								Vector(0,0,0), Vector(0,0,0),
								99999.0, 2080, Constants.ETFDmgCustom.TF_DMG_CUSTOM_BURNING)
}
Similar example but creates an ice statue on death instead.
// Ensure only one of this entity is ever spawned
if (!("freeze_proxy_weapon" in getroottable()))
{
	::freeze_proxy_weapon <- null
	::freeze_immune_conds <-
	[
		Constants.ETFCond.TF_COND_INVULNERABLE,
		Constants.ETFCond.TF_COND_PHASE,
		Constants.ETFCond.TF_COND_INVULNERABLE_HIDE_UNLESS_DAMAGED,
		Constants.ETFCond.TF_COND_INVULNERABLE_USER_BUFF,
		Constants.ETFCond.TF_COND_INVULNERABLE_CARD_EFFECT,
	]
}
// Create the fake weapon if one doesn't already exist
if (!freeze_proxy_weapon || !freeze_proxy_weapon.IsValid())
{
	freeze_proxy_weapon = Entities.CreateByClassname("tf_weapon_knife")
	NetProps.SetPropInt(freeze_proxy_weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 649)
	NetProps.SetPropBool(freeze_proxy_weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
	freeze_proxy_weapon.DispatchSpawn()
	freeze_proxy_weapon.DisableDraw()
	// Add the attribute that creates ice statues
	freeze_proxy_weapon.AddAttribute("freeze backstab victim", 1.0, -1.0)
}
function Precache()
{
	// Add an output to deal damage when the trigger is touched
	self.ConnectOutput("OnStartTouch", "Freeze")
}
function Freeze()
{
	// Remove conditions that give immunity to damage
	foreach (cond in freeze_immune_conds)
		activator.RemoveCondEx(cond, true)
	// Set any owner on the weapon to prevent a crash
	NetProps.SetPropEntity(freeze_proxy_weapon, "m_hOwner", activator)
	// Deal the damage with the weapon
	activator.TakeDamageCustom(self, activator, freeze_proxy_weapon,
								Vector(0,0,0), Vector(0,0,0),
								99999.0, Constants.FDmgType.DMG_CLUB, Constants.ETFDmgCustom.TF_DMG_CUSTOM_BACKSTAB)
	// I don't remember why this is needed but it's important
	local ragdoll = NetProps.GetPropEntity(activator, "m_hRagdoll")
	if (ragdoll)
		NetProps.SetPropInt(ragdoll, "m_iDamageCustom", 0)
}
Improving player collisions on physics objects or trains
 Team Fortress 2 has a subdued physics mode by default, which makes players poorly interact with physics objects unlike other Source games. This can be fixed by setting the
 Team Fortress 2 has a subdued physics mode by default, which makes players poorly interact with physics objects unlike other Source games. This can be fixed by setting the sv_turbophysics convar to 0.
Convars.SetValue("sv_turbophysics", 0)
func_tracktrains have similar problems. The game has a special train physics mode for Payload to circumvent this issue, which is normally not enabled in other gamemodes. This can be forcefully enabled (or disabled!) via the following:
ForceEscortPushLogic(2)
Force class change
When doing server-only scripts via mapspawn.nut, calling SetPlayerClass can either plain not work or work buggily. For example, looking at your loadout shows the class you tried to choose, rather than forced class and each time you spawn your class will revert back to what you tried to select. By also updating m_iDesiredPlayerClass, these aforementioned bugs no longer occur.
::ForceChangeClass <- function(player, class_index)
{
	player.SetPlayerClass(class_index)
	NetProps.SetPropInt(player, "m_Shared.m_iDesiredPlayerClass", class_index)
	player.ForceRegenerateAndRespawn()
}
ForceChangeClass(GetListenServerHost(), Constants.ETFClass.TF_CLASS_SCOUT)
Change how long it takes to capture a control point
When doing server-only scripts via mapspawn.nut, you may want to increase the amount of time it takes to capture a control point. The following snippet increases the time it takes to capture a control point by 2x.
function OnGameEvent_teamplay_round_start(params)
{
	// Must set this when the map resets only, otherwise the scaling compounds between rounds
	// If this is set elsewhere, it may not work as the m_flCapTime gets reset.
	if (params.full_reset)
	{
		for (local ent = null; ent = Entities.FindByClassname(ent, "trigger_capture_area");)
		{
			local cap_point_name = NetProps.GetPropString(ent, "m_iszCapPointName")
			local cap_time_in_seconds = NetProps.GetPropFloat(ent, "m_flCapTime")
			// Double the time it takes to capture a control point
			cap_time_in_seconds *= 2.0
			NetProps.SetPropFloat(ent, "m_flCapTime", cap_time_in_seconds)
			// If we do not fire this event, then cap time total does not propagate to the client
			// and the UI will be buggy and show the control point capping quicker then it should
			// and then "popping" back after the server updates the client.
			ent.AcceptInput("SetControlPoint", cap_point_name, null, null)
		}
	}
}
Making a model visible to only to a specific player
In the game, entities are usually networked to every player. It may be useful to show a model to a specific player only, which is what this example allows. Engineer buildings only show to their owner during their "blueprint" phase, and this fact can be abused to make a model only show to a desired player.
local proxy_entity = Entities.CreateByClassname("obj_teleporter") // not using SpawnEntityFromTable as that creates spawning noises
proxy_entity.SetAbsOrigin(Vector(0, -320, -150))
proxy_entity.DispatchSpawn()
proxy_entity.SetModel("models/player/heavy.mdl")
proxy_entity.AddEFlags(Constants.FEntityEFlags.EFL_NO_THINK_FUNCTION) // prevents the entity from disappearing
proxy_entity.SetSolid(Constants.ESolidType.SOLID_NONE)
NetProps.SetPropBool(proxy_entity, "m_bPlacing", true)
NetProps.SetPropInt(proxy_entity, "m_fObjectFlags", 2) // sets "attachment" flag, prevents entity being snapped to player feet
// m_hBuilder is the player who the entity will be networked to only
NetProps.SetPropEntity(proxy_entity, "m_hBuilder", GetListenServerHost())
Enabling the Spell HUD and Effects
By setting the m_bIsUsingSpells netprop to true, you can Enable the spell HUD, the sound effects for rolling spells, and set the spellbook magazine to the default action slot item.  Note that spells do work when this netprop is set to false, however the HUD elements/sound effects will not appear, and players will need to manually equip the spellbook if they have not already.
::SetSpellHUD <- function(value)
{
	local gamerules = Entities.FindByClassname(null, "tf_gamerules")
	NetProps.SetPropBool(gamerules, "m_bIsUsingSpells", value)
}
SetSpellHUD(true)
Changing Player Teams in MvM
When attempting to change to change the player to the blue team in MvM using ForceChangeTeam or SetTeam, they will be immediately switched back to RED.  To circumvent this, you can temporarily change the tf_gamerules NetProp m_bPlayingMannVsMachine to false, switch the player team, then set it back to true immediately after.  Note that you will need to manually set the robot models for players if you intend to make playable robots.
 Warning:Not setting this netprop back to true immediately after switching teams can cause many problems, including server crashes!
Warning:Not setting this netprop back to true immediately after switching teams can cause many problems, including server crashes!::ChangePlayerTeamMvM <- function(player, teamnum)
{
	local gamerules = Entities.FindByClassname(null, "tf_gamerules")
	NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", false)
	player.ForceChangeTeam(teamnum, false)
	NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", true)
}
ChangePlayerTeamMvM(GetListenServerHost(), Constants.ETFTeam.TF_TEAM_PVE_INVADERS) //Constants.ETFTeam.TF_TEAM_BLUE is also perfectly valid
Changing MvM Mission Names on the Scoreboard
You can change the displayed mission name on the scoreboard by changing the m_iszMvMPopfileName netprop on the tf_objective_resource entity.
 Note:This will always appear in uppercase on the scoreboard, as the game is expecting a file path to localize.
Note:This will always appear in uppercase on the scoreboard, as the game is expecting a file path to localize.local resource = Entities.FindByClassname(null, "tf_objective_resource")
NetProps.SetPropString(resource, "m_iszMvMPopfileName", "New Mission Name Here")
Disabling Thriller taunt in Halloween
 Note:A better way is to just add the
Note:A better way is to just add the special taunt attribute to all of the player's weapons, as that always prevents the Thriller taunt. The old way had a rare chance of failing to prevent the taunt. That old example remains here for prosperity.The following think function on a player will disable the Thriller taunt on Halloween mode and play the normal taunt instead.
const TF_CLASS_MEDIC = 5
const TF_COND_TAUNTING = 7
const TAUNT_BASE_WEAPON = 0
function NoThrillerThink()
{
	if (!self.IsTaunting())
		return -1
	for (local scene; scene = Entities.FindByClassname(scene, "instanced_scripted_scene");)
	{
		local owner = NetProps.GetPropEntity(scene, "m_hOwner")
		if (owner != self)
			continue
		local name = NetProps.GetPropString(scene, "m_szInstanceFilename")
		local thriller_name = self.GetPlayerClass() == TF_CLASS_MEDIC ? "taunt07" : "taunt06"
		if (name.find(thriller_name) == null)
			continue
		scene.Kill()
		self.RemoveCond(TF_COND_TAUNTING)
		self.Taunt(TAUNT_BASE_WEAPON, 0)
		break
	}
	return -1
}
Applying paints to any entity
Cosmetic paints can be applied to any entity such as prop_dynamic by setting the owner entity to a tf_wearable (or any other econ entity). See this guide for a list of paint values (under Painting Robot Items).
local model_name = "models/player/items/demo/demo_ellis.mdl"
local origin = GetListenServerHost().GetOrigin()
local paint = 15132390
local wearable = Entities.CreateByClassname("tf_wearable")
NetProps.SetPropBool(wearable, "m_AttributeManager.m_Item.m_bInitialized", true)
wearable.SetAbsOrigin(origin)
wearable.AddAttribute("set item tint RGB", paint, -1)
wearable.DispatchSpawn()
wearable.EnableDraw()
local prop = SpawnEntityFromTable("prop_dynamic",
{
	origin = origin,
	model = model_name
})
prop.SetOwner(wearable)
Readying up teams for mp_tournament 1 in offline
Gamemodes for Casual must be tested with mp_tournament 1 and mp_tournament_stopwatch 0 to be considered for inclusion. However this usually requires 2 real players to ready up for their team, which may be undesirable for offline testing. As a workaround, the following code snippet can be executed to pick any player from both teams and spoof readying up.
::GameRules <- Entities.FindByClassname(null, "tf_gamerules")
::MAX_CLIENTS <- MaxClients().tointeger()
const TF_TEAM_RED = 2
const TF_TEAM_BLUE = 3
local ready = true
local red_check = false
local blue_check = false
for (local i = 1; i <= MAX_CLIENTS; i++)
{
	local player = PlayerInstanceFromIndex(i)
	if (!player)
		continue
	local team = player.GetTeam()
	if (!(team & 2))
		continue
	if (team == TF_TEAM_RED)
	{
		if (red_check)
			continue
		red_check = true
	}
	else if (team == TF_TEAM_BLUE)
	{
		if (blue_check)
			continue
		blue_check = true
	}
	if (NetProps.GetPropBoolArray(GameRules, "m_bTeamReady", team))
		continue
	NetProps.SetPropBoolArray(GameRules, "m_bTeamReady", ready, team)
	SendGlobalGameEvent("tournament_stateupdate",
	{
		userid = player.entindex(),
		readystate = ready.tointeger(),
		namechange = 0,
		oldname = " ",
		newname = " ",
	})
	if (!ready)
	{
		NetProps.SetPropFloat(GameRules, "m_flRestartRoundTime", -1.0)
		NetProps.SetPropBool(GameRules, "m_bAwaitingReadyRestart", true)
	}
}
Forcing a level change
The code snippet below will force a level change after one second to the next map in the rotation.
function ChangeLevel(mapname = "", delay = 1.0, mvm_cyclemissionfile = false)
{
	// listen servers can just do this
	if (!IsDedicatedServer())
		SendToConsole(format("nextlevel %s", mapname))
	// check for allow point_servercommand
	else if (Convars.GetStr("sv_allow_point_servercommand") == "always")
		SendToServerConsole(format("nextlevel %s", mapname))
	// check the allowlist
	else if (Convars.IsConVarOnAllowList("nextlevel"))
		Convars.SetValue("nextlevel", mapname)
	// can't set it, just load the next map in the mapcycle file or hope the server sets nextlevel for us
	else
		printl("cannot set nextlevel! loading next map instead...")
	// required for GoToIntermission
	Convars.SetValue("mp_tournament", 0)
	// wait at scoreboard for this many seconds
	Convars.SetValue("mp_chattime", delay)
	local intermission = Entities.CreateByClassname("point_intermission")
	// for mvm, otherwise it'll ignore delay and switch to the next map in the missioncycle file
	if (!mvm_cyclemissionfile)
		EntFire("info_populator", "Kill")
	// don't use acceptinput so we execute after info_populator kill in mvm
	EntFireByHandle(intermission, "Activate", "", -1, null, null)
}
By default the next level cannot be controlled, as the nextlevel ConVar is not in cfg/vscript_convar_allowlist.txt by default.
If the server has not added nextlevel to their allow list, this can be worked around if the server has sv_allow_point_servercommand set to always.  On listen servers only you can also use SendToConsole which will always work.
 Note:In Mann vs Machine, this will instead cycle to the next map set in the servers
Note:In Mann vs Machine, this will instead cycle to the next map set in the servers tf_mvm_missioncyclefile and will immediately skip the mp_chattime delay, killing the info_populator ent first will circumvent this.  The above snippet does this by default but can be controlled in the third argument.
 E.g. ChangeLevel("", 1.0, true)Placing Sapper on a Building
Placing a sapper on an entity isn't supposed to be done by manual means, however it can be spoofed by selecting a random player and pretending they "sapped" the building, and reverting everything on the same frame which makes it look seamless.
 Warning:This has not been tested too much. There could be more edge cases where a player can't place a Sapper which need to be resolved.
Warning:This has not been tested too much. There could be more edge cases where a player can't place a Sapper which need to be resolved.Weapon_Switch.const MAX_WEAPONS = 8
const TF_CLASS_SPY = 8
const TF_CLASS_ENGINEER = 9
const TF_TEAM_RED = 2
const TF_TEAM_BLUE = 3
const LIFE_ALIVE = 0
const ID_SAPPER = 735
const OBJ_ATTACHMENT_SAPPER = 3
function SapBuilding(building)
{
	// select first player found
	local player = Entities.FindByClassname(null, "player")
	if (!player)
		return
	// find existing builder weapon if applicable
	local player_class = player.GetPlayerClass()
	local old_builder, old_slot
	if (player_class == TF_CLASS_SPY || player_class == TF_CLASS_ENGINEER)
	{
		for (local i = 0; i < MAX_WEAPONS; i++)
		{
			local held_weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
			if (held_weapon == null)
				continue
			if (held_weapon.GetClassname() != "tf_weapon_builder")
				continue
			old_builder = held_weapon
			old_slot = i
			NetProps.SetPropEntityArray(player, "m_hMyWeapons", null, i)
			break
		}
	}
	local old_lifestate = NetProps.GetPropInt(player, "m_lifeState")
	local old_team = player.GetTeam()
	local building_team = building.GetTeam()
	local enemy_team
	if (building_team == TF_TEAM_RED)
		enemy_team = TF_TEAM_BLUE
	else
		enemy_team = TF_TEAM_RED
	// spoof being alive and on opposite team
	NetProps.SetPropInt(player, "m_lifeState", LIFE_ALIVE)
	NetProps.SetPropInt(player, "m_iTeamNum", enemy_team)
	// give sapper weapon
	local weapon = Entities.CreateByClassname("tf_weapon_builder")
	NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", ID_SAPPER)
	NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
	NetProps.SetPropBool(weapon, "m_bValidatedAttachedEntity", true)
	weapon.SetTeam(enemy_team)
	weapon.DispatchSpawn()
	NetProps.SetPropInt(weapon, "m_iObjectType", OBJ_ATTACHMENT_SAPPER)
	NetProps.SetPropInt(weapon, "m_iSubType", OBJ_ATTACHMENT_SAPPER)
	NetProps.SetPropIntArray(weapon, "m_aBuildableObjectTypes", 1, OBJ_ATTACHMENT_SAPPER)
	player.Weapon_Equip(weapon)
	// teleport player to the building and hold the sapper
	local old_origin = player.GetOrigin()
	local old_angles = player.LocalEyeAngles()
	local old_weapon = player.GetActiveWeapon()
	player.SetAbsOrigin(building.GetOrigin() - Vector(64, 0, 0))
	NetProps.SetPropVector(player, "pl.v_angle", Vector())
	player.Weapon_Switch(weapon)
	weapon.PrimaryAttack()
	// remove owner from the sapper that was just placed
	for (local sapper; sapper = Entities.FindByClassname(sapper, "obj_attachment_sapper");)
	{
		if (NetProps.GetPropEntity(sapper, "m_hBuiltOnEntity") == building)
		{
			NetProps.SetPropEntity(sapper, "m_hBuilder", null)
			break
		}
	}
	// revert all changes
	if (old_builder)
		NetProps.SetPropEntityArray(player, "m_hMyWeapons", old_builder, old_slot)
	NetProps.SetPropInt(player, "m_lifeState", old_lifestate)
	NetProps.SetPropInt(player, "m_iTeamNum", old_team)
	player.Weapon_Switch(old_weapon)
	NetProps.SetPropVector(player, "pl.v_angle", old_angles + Vector())
	player.SetAbsOrigin(old_origin)
	weapon.Destroy()
}
// example
local building = Entities.FindByClassname(null, "obj_dispenser")
if (building)
{
	if (!NetProps.GetPropBool(building, "m_bHasSapper"))
		SapBuilding(building)
}
The following code show an example of creating a primitive bot that navigates using the navmesh. When this script is executed, a bot will be spawned at the host player's crosshair. This bot will simply follow the player and path-find along the map layout. The code is heavily commented, and debug visualizations are available to see the path-finding algorithm in action.
 Warning:Making bots is not an easy task. This code shows how much work is required just to setup a simple bot!
Warning:Making bots is not an easy task. This code shows how much work is required just to setup a simple bot!const FLT_MAX = 3.402823466e+38
const MASK_SOLID = 33570827
const LIFE_DYING = 1
const NUM_TRAVERSE_TYPES = 9
const TEAM_ANY = -2
const DMG_CRUSH = 1
// Constrains an angle into [-180, 180] range
::NormalizeAngle <- function(target)
{
	target %= 360.0
	if (target > 180.0)
		target -= 360.0
	else if (target < -180.0)
		target += 360.0
	return target
}
// Approaches an angle at a given speed
::ApproachAngle <- function(target, value, speed)
{
	target = NormalizeAngle(target)
	value = NormalizeAngle(value)
	local delta = NormalizeAngle(target - value)
	if (delta > speed)
		return value + speed
	else if (delta < -speed)
		return value - speed
	return value
}
// Converts a vector direction into angles
::VectorAngles <- function(forward)
{
	local yaw, pitch
	if (forward.y == 0.0 && forward.x == 0.0)
	{
		yaw = 0.0
		if (forward.z > 0.0)
			pitch = 270.0
		else
			pitch = 90.0
	}
	else
	{
		yaw = (atan2(forward.y, forward.x) * 180.0 / PI)
		if (yaw < 0.0)
			yaw += 360.0
		pitch = (atan2(-forward.z, forward.Length2D()) * 180.0 / PI)
		if (pitch < 0.0)
			pitch += 360.0
	}
	return QAngle(pitch, yaw, 0.0)
}
// Coordinate which is part of a path
class ::PathPoint
{
	constructor(_area, _pos, _how)
	{
		area = _area
		pos = _pos
		how = _how
	}
	area = null		// Which area does this point belong to?
	pos = null		// Coordinates of the point
	how = null		// Type of traversal. See Constants.ENavTraverseType
}
// The big boy that handles all our behavior
class ::Bot
{
	constructor(spawn_origin, target = null)
	{
		bot = SpawnEntityFromTable("base_boss",
		{
			targetname = "bot",
			origin = spawn_origin,
			model = "models/bots/heavy/bot_heavy.mdl",
			playbackrate = 1.0, // Required for animations to be simulated
			// The following is done to prevent default base_boss death behavior
			// Set the health to something really big
			health = FLT_MAX
		})
		// Track the health manually by using npc_hurt event and fire our custom death
		locomotion = bot.GetLocomotionInterface()
		health = 300
		// Fix the default step height which is too high
		bot.AcceptInput("SetStepHeight", "18", null, null)
		// Add scope to the entity
		bot.ValidateScriptScope()
		local scope = bot.GetScriptScope()
		// Append custom bot functionality
		scope.bot_brain <- this
		// Add behavior that will run every tick
		scope.Think <- Update.bindenv(this)
		AddThinkToEnt(bot, "Think")
		bot_pos = spawn_origin
		move_speed = 230.0
		turn_rate = 5.0
		search_dist_z = 128.0
		search_dist_nearest = 128.0
		path = []
		path_index = 0
		path_count = 0
		path_reach_dist = 16.0
		path_update_time_next = Time()
		path_update_time_delay = 0.2
		path_closest_distance = 100.0
		path_follow_ent_dist = 50.0
		area_list = {}
		// If the destination hasn't been set yet make it be at the bot's spawn location
		if (target == null)
			target = spawn_origin
		SetDestination(target)
		seq_idle = bot.LookupSequence("Stand_MELEE")
		seq_run = bot.LookupSequence("Run_MELEE")
		pose_move_x = bot.LookupPoseParameter("move_x")
		debug = true
	}
	function SetDestination(target)
	{
		// If our destination is an entity
		if (typeof target == "instance")
		{
			path_follow_ent = target
			// path_target_pos will be calculated in UpdatePath()
		}
		// If it's a vector
		else
		{
			path_follow_ent = null
			path_target_pos = target
		}
		// Force a path update on this frame
		if (UpdatePath())
			SetDirection(path[0].pos)
	}
	function SetDirection(pos)
	{
		move_pos = pos
		// Direction towards path point
		move_dir = move_pos - bot_pos
		move_dir.Norm()
		// Conversion from direction into QAngle form to calculate the bot rotation angles
		move_ang = VectorAngles(move_dir)
	}
	function UpdatePath()
	{
		// Clear out the path first
		ResetPath()
		// If there is a target entity specified, then the bot will pathfind to the entity
		if (path_follow_ent && path_follow_ent.IsValid())
			path_target_pos = path_follow_ent.GetOrigin()
		// Pathfind from the bot's position to the target position
		local pos_start = bot_pos
		local pos_end = path_target_pos
		local area_start = NavMesh.GetNavArea(pos_start, search_dist_z)
		local area_end = NavMesh.GetNavArea(pos_end, search_dist_z)
		// If either area was not found, try use the closest one
		if (area_start == null)
		{
			area_start = NavMesh.GetNearestNavArea(pos_start, search_dist_nearest, false, true)
			// If either area is still missing, then bot can't progress
			if (area_start == null)
				return false
		}
		if (area_end == null)
		{
			area_end = NavMesh.GetNearestNavArea(pos_end, search_dist_nearest, false, true)
			if (area_end == null)
				return false
		}
		// If the start and end area is the same, one path point is enough and all the expensive path building can be skipped
		if (area_start == area_end)
		{
			// From bot's origin
			path.append(PathPoint(area_start, pos_start, NUM_TRAVERSE_TYPES))
			// To target's position
			path.append(PathPoint(area_end, pos_end, NUM_TRAVERSE_TYPES))
			path_count = 2
			// For the debug mode
			area_list["area0"] <- area_start
			return true
		}
		// Build list of areas required to get from the start to the end
		if (!NavMesh.GetNavAreasFromBuildPath(area_start, area_end, pos_end, 0.0, TEAM_ANY, false, area_list))
			return false
		local area_count = area_list.len()
		// No areas found? Uh oh
		if (area_count == 0)
			return false
		// First point is simply our current position
		path.append(PathPoint(area_start, pos_start, NUM_TRAVERSE_TYPES))
		// Now build points using the list of areas, which the bot will then target
		// The areas are built from the end to the start so we need a reversed iteration to build the path points
		for (local i = area_count - 1; i >= 0; i--)
		{
			local area = area_list["area" + i]
			path.append(PathPoint(area, area.GetCenter(), area.GetParentHow()))
		}
		// Now compute accurate path points, using adjacent points + direction data from nav
		path_count = path.len()
		for (local i = 1; i < path_count; i++)
		{
			local point_from = path[i - 1]
			local point_to = path[i]
			// Computes closest point within the "portal" between adjacent areas
			point_to.pos = point_from.area.ComputeClosestPointInPortal(point_to.area, point_to.how, point_from.pos)
		}
		// Add a final point so the bot can precisely move towards the end point when it reaches the final area
		path.append(PathPoint(area_end, pos_end, NUM_TRAVERSE_TYPES))
		path_count++
		return true
	}
	function AdvancePath()
	{
		// Check for valid path first
		if (path_count == 0)
			return false
		// If we're close enough to the target stop to not push our target entity
		if (path_follow_ent && path_follow_ent.IsValid() &&
			(path_target_pos - bot_pos).Length() < path_closest_distance)
		{
			ResetPath()
			return false
		}
		// Are we close enough to the path point to consider it as 'reached'?
		if ((move_pos - bot_pos).Length2D() < path_reach_dist)
		{
			// Start moving to the next point
			path_index++
			if (path_index >= path_count)
			{
				// End of the line!
				ResetPath()
				return false
			}
		}
		return true
	}
	function ResetPath()
	{
		area_list.clear()
		path.clear()
		path_count = 0
		path_index = 0
	}
	function Move()
	{
		// Recompute path to our target if present
		if (path_follow_ent && path_follow_ent.IsValid())
		{
			// Is it time to re-compute the path?
			local time = Time()
			if (path_update_time_next < time)
			{
				// Check if target has moved far away enough
				local follow_ent_pos = path_follow_ent.GetOrigin()
				if ((path_target_pos - follow_ent_pos).Length() > path_follow_ent_dist &&
					(bot_pos - follow_ent_pos).Length() > path_closest_distance)
				{
					if (UpdatePath())
						SetDirection(path[0].pos)
					// Don't recompute again for a moment
					path_update_time_next = time + path_update_time_delay
				}
			}
		}
		// Check and advance up our path
		if (!AdvancePath())
		{
			return false
		}
		// Current path point
		local target = path[path_index].pos
		// Get 2D forward motion vector
		local motion_fwd = target - bot_pos
		motion_fwd.z = 0.0
		motion_fwd.Norm()
		// Get 2D lateral motion vector, which is perpendicular to the forward motion vector, going left
		local motion_lat = Vector(-motion_fwd.y, motion_fwd.x)
		// Get the ground normal and compute the 3D motion vectors
		local normal = locomotion.GetGroundNormal()
		motion_fwd = motion_lat.Cross(normal)
		motion_lat = motion_fwd.Cross(normal)
		
		// The position traces will rely on, shift 18 units upwards to avoid hitting the obstacle you can climb onto
		local pos = Vector(bot_pos.x, bot_pos.y, bot_pos.z + 18.0)
		// The length vector of the traces going forward
		local fwd_vec = motion_fwd * 60.0
		// The offset to the left/right from the original position
		local lat_vec = motion_lat * 20.0
		// Trace slightly to the left of the bot, going forward
		local left_pos = pos + lat_vec
		local left_trace =
		{
			start = left_pos
			end = left_pos + fwd_vec
			mask = MASK_SOLID
			ignore = bot
		}
		TraceLineEx(left_trace)
		// Trace slightly to the right of the bot, going forward
		local right_pos = pos - lat_vec
		local right_trace =
		{
			start = right_pos
			end = right_pos + fwd_vec
			mask = MASK_SOLID
			ignore = bot
		}
		TraceLineEx(right_trace)
		// If left trace hit something, but right didn't - move to the right
		// If right trace hit something, but left didn't - move to the left
		// Otherwise if 0 or 2 traces hit something, leave the target position as it was
		if (left_trace.hit)
		{
			if (!right_trace.hit)
			{
				target -= motion_lat * move_speed
			}
		}
		else if (right_trace.hit)
		{
			target += motion_lat * move_speed
		}
		SetDirection(target)
		// Set our new position
		// Velocity is calculated from direction times speed, and converted from per-second to per-tick time
		bot.SetAbsOrigin(bot_pos + (move_dir * move_speed * FrameTime()))
		// Visualize current path in debug mode
		if (debug)
		{
			// Stay around for 1 tick
			// Debugoverlays are created on 1st tick but start rendering on 2nd tick, hence this must be doubled
			local frame_time = FrameTime() * 2.0
			// Draw connected path points
			local path_start_index = path_index
			if (path_start_index == 0)
				path_start_index++
			for (local i = path_start_index; i < path_count; i++)
			{
				DebugDrawLine(path[i - 1].pos, path[i].pos, 0, 255, 0, true, frame_time)
			}
			// Draw areas from built path
			foreach (name, area in area_list)
			{
				area.DebugDrawFilled(255, 0, 0, 30, frame_time, true, 0.0)
				DebugDrawText(area.GetCenter(), name, false, frame_time)
			}
			// Left trace
			DebugDrawLine(left_trace.start, left_trace.end, left_trace.hit ? 0 : 255, left_trace.hit ? 255 : 0, 0, true, frame_time)
			// Right trace
			DebugDrawLine(right_trace.start, right_trace.end, right_trace.hit ? 0 : 255, right_trace.hit ? 255 : 0, 0, true, frame_time)
		}
		return true
	}
	function Update()
	{
		bot_pos = bot.GetOrigin()
		// Try moving
		if (Move())
		{
			// Moving, set the run animation
			if (bot.GetSequence() != seq_run)
			{
				bot.SetSequence(seq_run)
				bot.SetPoseParameter(pose_move_x, 1.0) // Set the move_x pose to max weight
			}
		}
		else
		{
			// Not moving, set the idle animation
			if (bot.GetSequence() != seq_idle)
			{
				bot.SetSequence(seq_idle)
				bot.SetPoseParameter(pose_move_x, 0.0) // Clear the move_x pose
			}
			// If the bot is standing still, look at the target instead of the path points
			if (path_follow_ent && path_follow_ent.IsValid())
				SetDirection(path_follow_ent.GetOrigin())
		}
		// Rotating the bot
		// Approach new desired angle but only on the Y axis
		local bot_ang = bot.GetAbsAngles()
		bot_ang.y = ApproachAngle(move_ang.y, bot_ang.y, turn_rate)
		// Set our new angles
		bot.SetAbsAngles(bot_ang)
		// Replay animation if it has finished
		if (bot.GetCycle() > 0.99)
			bot.SetCycle(0.0)
		// Run animations
		bot.StudioFrameAdvance()
		bot.DispatchAnimEvents(bot)
		return -1 // Think again next frame
	}
	function OnKilled()
	{
		// Change life state to "dying"
		// The bot won't take any more damage, and sentries will stop targeting it
		NetProps.SetPropInt(bot, "m_lifeState", LIFE_DYING)
		// For this example, turn into a ragdoll with the saved damage force
		bot.BecomeRagdollOnClient(damage_force)
		// Stop pathfinding
		AddThinkToEnt(bot, null)
		// Custom death behavior can be added here
	}
	bot = null						// The bot entity we belong to
	locomotion = null				// Bot's locomotion interface
	health = null					// Manual track of health to prevent default base_boss death behavior
	move_speed = null				// How fast to move
	turn_rate = null				// How fast to turn
	search_dist_z = null			// Maximum distance to look for a nav area downwards
	search_dist_nearest = null 		// Maximum distance to look for any nearby nav area
	bot_pos = null					// Origin of the bot
	move_pos = null					// Current target destination (path point)
	move_dir = null					// Current move direction
	move_ang = null					// Current move direction in angle form
	path = null						// List of BotPathPoints
	path_index = null				// Current path point bot is at, -1 if none
	path_count = null				// Number of path points
	path_follow_ent = null			// What entity to move towards
	path_follow_ent_dist = null		// Maximum distance after which the path is recomputed
									// if target entity's current position is too far from our target position
	path_closest_distance = null	// The closest the bot can get to an entity before stopping
									// required to not push the entity when we get too close
	path_reach_dist = null			// Distance to a path point to be considered as 'reached'
	path_target_pos = null			// Position where bot wants to navigate to
	path_update_time_next = null	// Timer for when to update path again
	path_update_time_delay = null   // Seconds to wait before trying to attempt to update path again
	area_list = null				// List of areas built in path
	seq_idle = null					// Animation to use when idle
	seq_run = null					// Animation to use when running
	pose_move_x = null				// Pose parameter to set for running animation
	damage_force = null				// Damage force from the bot's last OnTakeDamage event
	debug = null					// When true, debug visualization is enabled
}
::BotCreate <- function()
{
	// Find point where player is looking
	local player = GetListenServerHost()
	local eye_pos = player.EyePosition()
	local trace =
	{
		start = eye_pos,
		end = eye_pos + (player.EyeAngles().Forward() * 32768.0),
		ignore = player
	}
	if (!TraceLineEx(trace))
	{
		printl("Invalid bot spawn location")
		return null
	}
	// Spawn bot at the end point and start following the player
	return Bot(trace.pos, player)
}
// event listener (see Listening for Events example)
CollectEventsInScope
({
	function OnScriptHook_OnTakeDamage(params)
	{
		local victim = params.const_entity
		local scope = victim.GetScriptScope()
		if (victim.IsPlayer() && "bot_brain" in params.inflictor.GetScriptScope()
			&& params.damage_type == DMG_CRUSH)
		{
			// Don't crush the player if a bot pushes them into a wall
			params.damage = 0
		}
		if ("bot_brain" in scope)
		{
			// Save the damage force into the bot's data
			scope.bot_brain.damage_force = params.damage_force
		}
	}
	function OnGameEvent_npc_hurt(params)
	{
		local victim = EntIndexToHScript(params.entindex)
		local scope = victim.GetScriptScope()
		if ("bot_brain" in scope)
		{
			// Substract the damage dealt from our manual health track
			scope.bot_brain.health -= params.damageamount
			// Check if a bot is about to die
			if (scope.bot_brain.health <= 0)
			{
				// Run the bot's OnKilled function
				scope.bot_brain.OnKilled()
			}
		}
	}
})
::LastBot <- BotCreate()
// to dynamically change the bot's destination you can write the following in the console
// script LastBot.SetDestination(Vector(100.0, 100.0, 0.0))
// script LastBot.SetDestination(GetListenServerHost())
// script LastBot.SetDestination(GetListenServerHost().GetOrigin())
// etc
See also
 List of Team Fortress 2 Script Functions List of Team Fortress 2 Script Functions
- List of Script Libraries, these can also be useful as examples
- (WIP) Community Contributions, Contributions submitted by TF2Maps


























