Tower
class, but first, duplicate its contents for later use in a specific LaserTower
class. Then we remove all the laser-related code from Tower
. The tower may not keep track of a specific target, so delete the target
field and change the AcquireTarget
and TrackTarget
so that the output parameter is used as the link parameter. Then we will remove the OnDrawGizmosSelected
visualization from OnDrawGizmosSelected
, but we will leave the aiming range, because it is used for all towers. using UnityEngine; public abstract class Tower : GameTileContent { const int enemyLayerMask = 1 << 9; static Collider[] targetsBuffer = new Collider[100]; [SerializeField, Range(1.5f, 10.5f)] protected float targetingRange = 1.5f; protected bool AcquireTarget (out TargetPoint target) { … } protected bool TrackTarget (ref TargetPoint target) { … } void OnDrawGizmosSelected () { Gizmos.color = Color.yellow; Vector3 position = transform.localPosition; position.y += 0.01f; Gizmos.DrawWireSphere(position, targetingRange); } }
LaserTower
that extends the Tower
and uses the functionality of its base class, getting rid of the duplicate code. using UnityEngine; public class LaserTower : Tower { [SerializeField, Range(1f, 100f)] float damagePerSecond = 10f; [SerializeField] Transform turret = default, laserBeam = default; TargetPoint target; Vector3 laserBeamScale; void Awake () { laserBeamScale = laserBeam.localScale; } public override void GameUpdate () { if (TrackTarget(ref target) || AcquireTarget(out target)) { Shoot(); } else { laserBeam.localScale = Vector3.zero; } } void Shoot () { … } }
TowerType
enumeration similar to GameTileContentType
. We will create support for the existing laser tower and mortar tower, which we will create later. public enum TowerType { Laser, Mortar }
Tower
to indicate its type. This works similarly to the type of behavior of a figure in the Object Management series of tutorials. public abstract TowerType TowerType€ { get; }
LaserTower
so that it returns the correct type. public override TowerType TowerType€ => TowerType.Laser;
GameTileContentFactory
so that the factory can produce the tower of the desired type. We implement this with an array of towers and add an alternative public Get
method with the TowerType
parameter. To verify that the array is configured correctly, we will use assertions. Another public Get
method will now only apply to the contents of tiles without towers. [SerializeField] Tower[] towerPrefabs = default; public GameTileContent Get (GameTileContentType type) { switch (type) { … } Debug.Assert(false, "Unsupported non-tower type: " + type); return null; } public GameTileContent Get (TowerType type) { Debug.Assert((int)type < towerPrefabs.Length, "Unsupported tower type!"); Tower prefab = towerPrefabs[(int)type]; Debug.Assert(type == prefab.TowerType€, "Tower prefab at wrong index!"); return Get(prefab); }
Get
method should be Tower
. But the private Get
method used to instantiate the prefab returns a GameTileContent
. Here you can either perform the conversion, or make the private Get
method generic. Let's choose the second option. public Tower Get (TowerType type) { … } T Get<T> (T prefab) where T : GameTileContent { T instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; }
GameBoard.ToggleTower
so that it requires the TowerType
parameter and TowerType
it to the factory. public void ToggleTower (GameTile tile, TowerType towerType) { if (tile.Content.Type == GameTileContentType.Tower€) { … } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(towerType); … } else if (tile.Content.Type == GameTileContentType.Wall) { tile.Content = contentFactory.Get(towerType); updatingContent.Add(tile.Content); } }
if (tile.Content.Type == GameTileContentType.Tower€) { updatingContent.Remove(tile.Content); if (((Tower)tile.Content).TowerType€ == towerType) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(towerType); updatingContent.Add(tile.Content); } }
Game
should now track the type of switchable tower. We simply denote each type of tower by a number. The laser tower is 1, it will be the default tower, and the mortar tower is 2. By pressing the number keys, we will select the appropriate type of towers. TowerType selectedTowerType; … void Update () { … if (Input.GetKeyDown(KeyCode.G)) { board.ShowGrid = !board.ShowGrid; } if (Input.GetKeyDown(KeyCode.Alpha1)) { selectedTowerType = TowerType.Laser; } else if (Input.GetKeyDown(KeyCode.Alpha2)) { selectedTowerType = TowerType.Mortar; } … } … void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { if (Input.GetKey(KeyCode.LeftShift)) { board.ToggleTower(tile, selectedTowerType); } else { board.ToggleWall(tile); } } }
MortarTower
type. Mortars have a frequency of fire, to indicate which you can use the “shots per second” configuration field. In addition, we will need a link to the mortar so that it can aim. using UnityEngine; public class MortarTower : Tower { [SerializeField, Range(0.5f, 2f)] float shotsPerSecond = 1f; [SerializeField] Transform mortar = default; public override TowerType TowerType€ => TowerType.Mortar; }
turret
to mortar
, move it down so that it stands on top of the base, give it a light gray color and attach it. We can leave the mortar collider, in this case, using a separate object, which is a simple collider superimposed on the default mortar orientation. I assigned a mortar range of 3.5 and a frequency of 1 shot per second.MortarTower
method to GameUpdate
, which always calls the Launch
method. Instead of firing a real projectile, we will visualize mathematical calculations for now. The shot point is the position of the mortar in the world, which is located just above the ground. We place the target point three units from it along the X axis, and zero out the Y component, because we always aim at the ground. Then we will show the points by Debug.DrawLine
yellow line between them by calling Debug.DrawLine
. The line will be visible in scene mode for one frame, but this is enough, because in each frame we draw a new line. public override void GameUpdate () { Launch(); } public void Launch () { Vector3 launchPoint = mortar.position; Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z); Debug.DrawLine(launchPoint, targetPoint, Color.yellow); }
Vector3 launchPoint = mortar.position; Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z); float x = 3f; float y = -launchPoint.y;
Launch
and will call it with four displacements in XZ: , , and . When the aiming point becomes equal to the point of the shot plus this offset, and then its Y coordinate becomes equal to zero. public override void GameUpdate () { Launch(new Vector3(3f, 0f, 0f)); Launch(new Vector3(0f, 0f, 1f)); Launch(new Vector3(1f, 0f, 1f)); Launch(new Vector3(3f, 0f, 1f)); } public void Launch (Vector3 offset) { Vector3 launchPoint = mortar.position; Vector3 targetPoint = launchPoint + offset; targetPoint.y = 0f; … }
Vector2 dir; dir.x = targetPoint.x - launchPoint.x; dir.y = targetPoint.z - launchPoint.z; float x = dir.magnitude; float y = -launchPoint.y; dir /= x; Debug.DrawLine(launchPoint, targetPoint, Color.yellow); Debug.DrawLine( new Vector3(launchPoint.x, 0.01f, launchPoint.z), new Vector3( launchPoint.x + dir.x * x, 0.01f, launchPoint.z + dir.y * x ), Color.white );
Mathf.Atan
. First, let's use a constant shot speed of 5. float x = dir.magnitude; float y = -launchPoint.y; dir /= x; float g = 9.81f; float s = 5f; float s2 = s * s; float r = s2 * s2 - g * (g * x * x + 2f * y * s2); float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x); float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta)); float sinTheta = cosTheta * tanTheta;
float sinTheta = cosTheta * tanTheta; Vector3 prev = launchPoint, next; for (int i = 1; i <= 10; i++) { float t = i / 10f; float dx = s * cosTheta * t; float dy = s * sinTheta * t - 0.5f * g * t * t; next = launchPoint + new Vector3(dir.x * dx, dy, dir.y * dx); Debug.DrawLine(prev, next, Color.blue); prev = next; }
float s = 4f;
float r = s2 * s2 - g * (g * x * x + 2f * y * s2); Debug.Assert(r >= 0f, "Launch velocity insufficient for range!");
Awake
and OnValidate
. float launchSpeed; void Awake () { OnValidate(); } void OnValidate () { float x = targetingRange; float y = -mortar.position.y; launchSpeed = Mathf.Sqrt(9.81f * (y + Mathf.Sqrt(x * x + y * y))); }
float x = targetingRange + 0.25001f;
Launch
. float s = launchSpeed;
Launch
point to the target. public void Launch (TargetPoint target) { Vector3 launchPoint = mortar.position; Vector3 targetPoint = target.Position; targetPoint.y = 0f; … }
GameUpdate
. But at this point, there may not be any goals available. In this case, we continue the process of firing, but without further accumulation. To avoid an infinite loop, you need to make it a little less than 1. float launchProgress; … public override void GameUpdate () { launchProgress += shotsPerSecond * Time.deltaTime; while (launchProgress >= 1f) { if (AcquireTarget(out TargetPoint target)) { Launch(target); launchProgress -= 1f; } else { launchProgress = 0.999f; } } }
Quaternion.LookRotation
. We also need with apply shot angle for component Y of the direction vector. This will work because the horizontal direction has a length of 1, i.e. . float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x); float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta)); float sinTheta = cosTheta * tanTheta; mortar.localRotation = Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y));
Debug.DrawLine
that allows them to be drawn for a long time. Vector3 prev = launchPoint, next; for (int i = 1; i <= 10; i++) { … Debug.DrawLine(prev, next, Color.blue, 1f); prev = next; } Debug.DrawLine(launchPoint, targetPoint, Color.yellow, 1f); Debug.DrawLine( … Color.white, 1f );
WarEntity
with a property OriginFactory
and method Recycle
. using UnityEngine; public abstract class WarEntity : MonoBehaviour { WarFactory originFactory; public WarFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } public void Recycle () { originFactory.Reclaim(this); } }
Shell
for the shells. using UnityEngine; public class Shell : WarEntity { }
WarFactory
one that will create the shell using the public getter property. using UnityEngine; [CreateAssetMenu] public class WarFactory : GameObjectFactory { [SerializeField] Shell shellPrefab = default; public Shell Shell€ => Get(shellPrefab); T Get<T> (T prefab) where T : WarEntity { T instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; } public void Reclaim (WarEntity entity) { Debug.Assert(entity.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(entity.gameObject); } }
Shell
. Then create the factory asset and assign it the prefab of the projectile.Game
to update the status of enemies. In fact, we can even make this approach generalized by creating an abstract component GameBehavior
that extends MonoBehaviour
and adds a virtual method GameUpdate
. using UnityEngine; public abstract class GameBehavior : MonoBehaviour { public virtual bool GameUpdate () => true; }
EnemyCollection
, turning it into GameBehaviorCollection
. public class GameBehaviorCollection { List<GameBehavior> behaviors = new List<GameBehavior>(); public void Add (GameBehavior behavior) { behaviors.Add(behavior); } public void GameUpdate () { for (int i = 0; i < behaviors.Count; i++) { if (!behaviors[i].GameUpdate()) { int lastIndex = behaviors.Count - 1; behaviors[i] = behaviors[lastIndex]; behaviors.RemoveAt(lastIndex); i -= 1; } } } }
WarEntity
expand GameBehavior
, not MonoBehavior
. public abstract class WarEntity : GameBehavior { … }
Enemy
, this time overriding the method GameUpdate
. public class Enemy : GameBehavior { … public override bool GameUpdate () { … } … }
Game
it will have to track two collections, one for enemies, the other for non-enemies. Non-enemies must be updated after everything else. GameBehaviorCollection enemies = new GameBehaviorCollection(); GameBehaviorCollection nonEnemies = new GameBehaviorCollection(); … void Update () { … enemies.GameUpdate(); Physics.SyncTransforms(); board.GameUpdate(); nonEnemies.GameUpdate(); }
Game
that will be a static facade for a war factory so that projectiles can be created by a challenge Game.SpawnShell()
. For this to work, you Game
must have a link to war factory and keep track of your own instance. [SerializeField] WarFactory warFactory = default; … static Game instance; public static Shell SpawnShell () { Shell shell = instance.warFactory.Shell€; instance.nonEnemies.Add(shell); return shell; } void OnEnable () { instance = this; }
Shell
method Initialize
and use it to set the shot point, target point and shot speed. Vector3 launchPoint, targetPoint, launchVelocity; public void Initialize ( Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity ) { this.launchPoint = launchPoint; this.targetPoint = targetPoint; this.launchVelocity = launchVelocity; }
MortarTower.Launch
and send it on the road. mortar.localRotation = Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y)); Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y) );
Shell
move, we need to track the duration of its existence, that is, the time elapsed since the shot. Then we can calculate his position in GameUpdate
. We always do this with respect to its firing point, so that the projectile perfectly follows the path regardless of the refresh rate. float age; … public override bool GameUpdate () { age += Time.deltaTime; Vector3 p = launchPoint + launchVelocity * age; py -= 0.5f * 9.81f * age * age; transform.localPosition = p; return true; }
public override bool GameUpdate () { … Vector3 d = launchVelocity; dy -= 9.81f * age; transform.localRotation = Quaternion.LookRotation(d); return true; }
MortarTower.Launch
trajectories from the visualization. public void Launch (TargetPoint target) { … Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y) ); }
Shell.GameUpdate
see if the vertical position is below zero. You can do this immediately after calculating them, before changing the position and turning the projectile. public override bool GameUpdate () { age += Time.deltaTime; Vector3 p = launchPoint + launchVelocity * age; py -= 0.5f * 9.81f * age * age; if (py <= 0f) { OriginFactory.Reclaim(this); return false; } transform.localPosition = p; … }
MortarTower
configuration options for them. [SerializeField, Range(0.5f, 3f)] float shellBlastRadius = 1f; [SerializeField, Range(1f, 100f)] float shellDamage = 10f;
Shell
and its method Initialize
. float age, blastRadius, damage; public void Initialize ( Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity, float blastRadius, float damage ) { … this.blastRadius = blastRadius; this.damage = damage; }
MortarTower
should only transmit data to the projectile after its creation. Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y), shellBlastRadius, shellDamage );
Tower
. Since it is useful for everything that needs a goal, copy its functionality into TargetPoint
and make it statically available. Add a method to fill the buffer, a property to get the buffered amount, and a method to get the buffered target. const int enemyLayerMask = 1 << 9; static Collider[] buffer = new Collider[100]; public static int BufferedCount { get; private set; } public static bool FillBuffer (Vector3 position, float range) { Vector3 top = position; top.y += 3f; BufferedCount = Physics.OverlapCapsuleNonAlloc( position, top, range, buffer, enemyLayerMask ); return BufferedCount > 0; } public static TargetPoint GetBuffered (int index) { var target = buffer[index].GetComponent<TargetPoint>(); Debug.Assert(target != null, "Targeted non-enemy!", buffer[0]); return target; }
Shell
. if (py <= 0f) { TargetPoint.FillBuffer(targetPoint, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy€.ApplyDamage(damage); } OriginFactory.Reclaim(this); return false; }
TargetPoint
static property to get a random target from the buffer. public static TargetPoint RandomBuffered => GetBuffered(Random.Range(0, BufferedCount));
Tower
, because now you can use to search for a random target TargetPoint
. protected bool AcquireTarget (out TargetPoint target) { if (TargetPoint.FillBuffer(transform.localPosition, targetingRange)) { target = TargetPoint.RandomBuffered; return true; } target = null; return false; }
Explosion
with a custom duration. Half a second will be enough. Add her a method Initialize
that sets the position and radius of the explosion. When setting the scale, you need to double the radius, because the radius of the sphere mesh is 0.5. It is also a good place to deal damage to all enemies within range, so we’ll also add a damage parameter. In addition, he needs a method GameUpdate
to check if time is running out. using UnityEngine; public class Explosion : WarEntity { [SerializeField, Range(0f, 1f)] float duration = 0.5f; float age; public void Initialize (Vector3 position, float blastRadius, float damage) { TargetPoint.FillBuffer(position, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage); } transform.localPosition = position; transform.localScale = Vector3.one * (2f * blastRadius); } public override bool GameUpdate () { age += Time.deltaTime; if (age >= duration) { OriginFactory.Reclaim(this); return false; } return true; } }
WarFactory
. [SerializeField] Explosion explosionPrefab = default; [SerializeField] Shell shellPrefab = default; public Explosion Explosion€ => Get(explosionPrefab); public Shell Shell => Get(shellPrefab);
Game
facade method. public static Explosion SpawnExplosion () { Explosion explosion = instance.warFactory.Explosion€; instance.nonEnemies.Add(explosion); return explosion; }
Shell
can generate and initiate an explosion upon reaching the target. The explosion itself will cause damage. if (py <= 0f) { Game.SpawnExplosion().Initialize(targetPoint, blastRadius, damage); OriginFactory.Reclaim(this); return false; }
Explosion
two configuration fields to this AnimationCurve
. We will use the curves to adjust the values over the life of the explosion, and time 1 will indicate the end of the explosion, regardless of its true duration. The same applies to the scale and radius of the explosion. This will simplify their configuration. [SerializeField] AnimationCurve opacityCurve = default; [SerializeField] AnimationCurve scaleCurve = default;
GameUpdate
, but we need to track using the radius field. You Initialize
can use the doubling scale. The values of the curves are found by calling them Evaluate
with an argument, calculated as the current lifetime of the explosion, divided by the duration of the explosion. static int colorPropertyID = Shader.PropertyToID("_Color"); static MaterialPropertyBlock propertyBlock; … float scale; MeshRenderer meshRenderer; void Awake () { meshRenderer = GetComponent<MeshRenderer>(); Debug.Assert(meshRenderer != null, "Explosion without renderer!"); } public void Initialize (Vector3 position, float blastRadius, float damage) { … transform.localPosition = position; scale = 2f * blastRadius; } public override bool GameUpdate () { … if (propertyBlock == null) { propertyBlock = new MaterialPropertyBlock(); } float t = age / duration; Color c = Color.clear; ca = opacityCurve.Evaluate(t); propertyBlock.SetColor(colorPropertyID, c); meshRenderer.SetPropertyBlock(propertyBlock); transform.localScale = Vector3.one * (scale * scaleCurve.Evaluate(t)); return true; }
Shell
creates a small explosion in each frame. These explosions will not cause any damage, so capturing targets will be a waste of resources. Add toExplosion
support for this use by making the damage be done if it is greater than zero, and then make the damage parameter Initialize
optional. public void Initialize ( Vector3 position, float blastRadius, float damage = 0f ) { if (damage > 0f) { TargetPoint.FillBuffer(position, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage); } } transform.localPosition = position; radius = 2f * blastRadius; }
Shell.GameUpdate
with a small radius, for example, 0.1, in order to turn them into tracer shells. It should be noted that with this approach, explosions will be created frame by frame, that is, they depend on the frame rate, but for such a simple effect this is permissible. public override bool GameUpdate () { … Game.SpawnExplosion().Initialize(p, 0.1f); return true; }
Source: https://habr.com/ru/post/461605/
All Articles