Replace square with cone for Basic Arrow Tower.

This commit is contained in:
Ben Calegari 2026-06-23 21:02:40 -07:00
parent 90fd1c23d3
commit 430e66bfbf
7 changed files with 167 additions and 6 deletions

View file

@ -0,0 +1,126 @@
// Assets/_Project/Scripts/Visuals/ConeMesh.cs
using UnityEngine;
namespace TD.Visuals
{
/// <summary>
/// Procedurally generates a cone mesh and assigns it to the GameObject's
/// <see cref="MeshFilter"/>. Unity ships no built-in cone primitive, so this
/// fills that gap for tower visuals (e.g. the Basic Arrow Tower).
/// </summary>
/// <remarks>
/// <b>Visual only.</b> No networking, no collision — the mesh is purely cosmetic.
/// The existing MeshRenderer + material (and any owner/paint tinting via
/// <c>TowerInstance.tintedRenderers</c>) keep working unchanged.
///
/// <b>Pivot.</b> The cone is centred on the local origin: its base ring sits at
/// <c>y = -height/2</c> and its apex at <c>y = +height/2</c>, matching the vertical
/// extents of a unit cube so it sits on a build tile the same way the old cube did.
///
/// <b>Edit-time.</b> <c>[ExecuteAlways]</c> rebuilds the mesh in the editor so the
/// cone is visible without entering play mode. The generated mesh is flagged
/// <c>HideFlags.DontSave</c> so it is never written out as a project asset.
/// </remarks>
[ExecuteAlways]
[RequireComponent(typeof(MeshFilter))]
public class ConeMesh : MonoBehaviour
{
[Tooltip("Number of radial segments around the base. Higher = smoother circle.")]
[Range(3, 64)]
[SerializeField] private int sides = 16;
[Tooltip("Radius of the cone's base in local units.")]
[Min(0.001f)]
[SerializeField] private float radius = 0.5f;
[Tooltip("Height of the cone from base to apex in local units.")]
[Min(0.001f)]
[SerializeField] private float height = 1f;
private Mesh mesh;
private void OnEnable() => Rebuild();
private void OnValidate()
{
// OnValidate can fire before OnEnable in the editor; guard the component.
if (isActiveAndEnabled) Rebuild();
}
private void OnDisable()
{
// Clean up the generated mesh so it doesn't linger when the object is destroyed.
if (mesh != null)
{
if (Application.isPlaying) Destroy(mesh);
else DestroyImmediate(mesh);
mesh = null;
}
}
private void Rebuild()
{
var filter = GetComponent<MeshFilter>();
if (filter == null) return;
if (mesh == null)
{
mesh = new Mesh { name = "ProceduralCone", hideFlags = HideFlags.DontSave };
}
BuildCone(mesh);
filter.sharedMesh = mesh;
}
private void BuildCone(Mesh target)
{
target.Clear();
float halfHeight = height * 0.5f;
// Vertex layout:
// [0 .. sides-1] base ring
// [sides] apex
// [sides + 1] base centre (for the bottom cap)
int baseRingStart = 0;
int apexIndex = sides;
int baseCentreIndex = sides + 1;
var vertices = new Vector3[sides + 2];
for (int i = 0; i < sides; i++)
{
float angle = (i / (float)sides) * Mathf.PI * 2f;
vertices[baseRingStart + i] = new Vector3(
Mathf.Cos(angle) * radius,
-halfHeight,
Mathf.Sin(angle) * radius);
}
vertices[apexIndex] = new Vector3(0f, halfHeight, 0f);
vertices[baseCentreIndex] = new Vector3(0f, -halfHeight, 0f);
// Side faces (base ring -> apex) + base cap (fan from centre).
// Wind both so they face outward / downward respectively.
var triangles = new int[sides * 3 * 2];
int t = 0;
for (int i = 0; i < sides; i++)
{
int next = (i + 1) % sides;
// Side triangle, outward-facing.
triangles[t++] = baseRingStart + i;
triangles[t++] = apexIndex;
triangles[t++] = baseRingStart + next;
// Base cap triangle, downward-facing.
triangles[t++] = baseCentreIndex;
triangles[t++] = baseRingStart + next;
triangles[t++] = baseRingStart + i;
}
target.vertices = vertices;
target.triangles = triangles;
target.RecalculateNormals();
target.RecalculateBounds();
}
}
}