第一篇 Unity 開發日誌:建立粒子模擬系統

郭俊鑫郭俊鑫
10 min read
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
using System.IO;
using System.Diagnostics;

public class ParticleSimulator2D : MonoBehaviour
{
    // 所有需要的欄位變數

    public GameObject particlePrefab;
    public GameObject labelPrefab;
    public GameObject wallPrefab;
    public TMP_InputField particleCountInput;
    public TMP_InputField speedLimitInput;
    public TMP_Text logOutputText;
    public Canvas worldCanvas;

    public Vector2 spawnAreaSize = new Vector2(10f, 10f);
    public float logInterval = 1.0f;
    public PhysicsMaterial2D particlePhysicsMaterial;
    public float gravitationalConstant = 1f;
    public float gravityCutoffDistance = 5f;
    public float mergeDistance = 0.2f;
    public bool ignoreCutoff = false;
    public float baseParticleScale = 0.2f;
    public int maxParticles = 200;
    public Toggle ignoreCutoffToggle;
    public TMP_Text ignoreCutoffLabel;
    public Slider gravityCutoffSlider;
    public TMP_Text gravityCutoffValueText;
    public TMP_Text particleCountText;

    private List<GameObject> particles = new List<GameObject>();
    private List<TMP_Text> labels = new List<TMP_Text>();
    private List<Color> particleColors = new List<Color>();
    private List<int> particleIDs = new List<int>();
    private int nextParticleID = 0;
    private float logTimer = 0f;
    private string logFilePath;
    private List<string> collisionLog = new List<string>();

    // 能量紀錄用
    private List<float> energyHistory = new List<float>();
    public RectTransform energyGraphContainer;
    public GameObject energyBarPrefab;
    private float energyLogInterval = 0.5f;
    public TMP_Text energyValueText;
    private float energyLogTimer = 0f;
    private int maxEnergyBars = 100;
    private Vector2 minBounds;
    private Vector2 maxBounds;

    private Color GetColorByMass(float mass)
    {
        return mass switch
        {
            < 2f => Color.blue,
            < 4f => Color.green,
            < 8f => Color.yellow,
            < 16f => new Color(1f, 0.5f, 0f), // orange
            _ => Color.red
        };
    }

private void Start()
{
    CreateBoundaryWalls();
}

private void CreateBoundaryWalls()
{
    Vector2 center = transform.position;
    Vector2 halfSize = spawnAreaSize / 2f;

    Vector2[] positions = new Vector2[] {
        center + new Vector2(0,  halfSize.y + 0.5f), // Top
        center + new Vector2(0, -halfSize.y - 0.5f), // Bottom
        center + new Vector2(-halfSize.x - 0.5f, 0), // Left
        center + new Vector2(halfSize.x + 0.5f, 0)   // Right
    };

    Vector2[] sizes = new Vector2[] {
        new Vector2(spawnAreaSize.x + 2f, 1f),
        new Vector2(spawnAreaSize.x + 2f, 1f),
        new Vector2(1f, spawnAreaSize.y + 2f),
        new Vector2(1f, spawnAreaSize.y + 2f)
    };

    for (int i = 0; i < 4; i++)
    {
        GameObject wall = Instantiate(wallPrefab, positions[i], Quaternion.identity);
        wall.transform.localScale = sizes[i];
        wall.name = "Wall_" + i;
    }
}

    private void Update()
    {
        energyLogTimer += Time.deltaTime;
        if (energyLogTimer >= energyLogInterval)
        {
            energyLogTimer = 0f;
            LogTotalKineticEnergy();
        }

        if (ignoreCutoffToggle != null)
        {
            ignoreCutoff = ignoreCutoffToggle.isOn;
            if (ignoreCutoffLabel != null)
                ignoreCutoffLabel.text = ignoreCutoff ? "遠距離仍作用引力 (ON)" : "遠距離仍作用引力 (OFF)";
        }

        if (gravityCutoffSlider != null)
        {
            gravityCutoffDistance = gravityCutoffSlider.value;
            if (gravityCutoffValueText != null)
                gravityCutoffValueText.text = $"作用範圍距離:{gravityCutoffDistance:F1}";
        }
        if (string.IsNullOrEmpty(logFilePath)) return;

        logTimer += Time.deltaTime;
        if (logTimer >= logInterval)
        {
            logTimer = 0f;
            LogParticleStates();
        }

        ApplyGravitationalForces();
        ClampParticlesToBounds();

        if (particleCountText) particleCountText.text = $"粒子數:{particles.Count} / {maxParticles}";

        for (int i = 0; i < particles.Count; i++)
        {
            if (!particles[i] || !labels[i]) continue;
            int id = i < particleIDs.Count ? particleIDs[i] : -1;
            var rb = particles[i].GetComponent<Rigidbody2D>();
            if (!rb) continue;

            Vector2 pos = rb.position;
            Vector2 vel = rb.velocity;
            labels[i].text = $"ID:{id}\nPos({pos.x:F1},{pos.y:F1})\nVel({vel.x:F1},{vel.y:F1})";


            Vector3 screenPos = Camera.main.WorldToScreenPoint(pos);
            labels[i].transform.position = screenPos + new Vector3(0, 30f, 0);

            float margin = 20f;
            bool offscreen = screenPos.x < margin || screenPos.x > Screen.width - margin ||
                             screenPos.y < margin || screenPos.y > Screen.height - margin;

            labels[i].enabled = !offscreen;
        }
    }

    private void ApplyGravitationalForces()
{
    for (int i = 0; i < particles.Count; i++)
    {
        Rigidbody2D rbA = particles[i].GetComponent<Rigidbody2D>();
        if (rbA == null) continue;

        for (int j = i + 1; j < particles.Count; j++)
        {
            HandlePairwiseInteraction(i, j, rbA);
        }
    }
}

private void HandlePairwiseInteraction(int i, int j, Rigidbody2D rbA)
{
    Rigidbody2D rbB = particles[j].GetComponent<Rigidbody2D>();
    if (rbB == null) return;

    Vector2 direction = rbB.position - rbA.position;
    float distance = direction.magnitude;
    if (distance < 0.1f) distance = 0.1f;

    float relativeVelocity = (rbA.velocity - rbB.velocity).magnitude;

    if (distance < mergeDistance)
    {
        if (relativeVelocity < 0.5f)
        {
            HandleMerge(i, j, rbA, rbB);
            return;
        }
        else if (relativeVelocity > 3f && rbA.mass >= 1f && rbB.mass >= 1f)
        {
            HandleFragmentation(i, j, rbA, rbB);
            return;
        }
    }

    if (!ignoreCutoff && distance > gravityCutoffDistance) return;

    Vector2 force = gravitationalConstant * (rbA.mass * rbB.mass / (distance * distance)) * direction.normalized;
    rbA.AddForce(force);
    rbB.AddForce(-force);
}

private void HandleMerge(int i, int j, Rigidbody2D rbA, Rigidbody2D rbB)
{
    float totalMass = rbA.mass + rbB.mass;
    rbA.velocity = (rbA.velocity * rbA.mass + rbB.velocity * rbB.mass) / totalMass;
    rbA.mass = totalMass;
    int idA = i < particleIDs.Count ? particleIDs[i] : -1;
    int idB = j < particleIDs.Count ? particleIDs[j] : -1;
    collisionLog.Add($"{Time.time:F2}s: Merge {idA} + {idB}{idA}, NewMass={totalMass:F1}");

    Destroy(particles[j]);
    Destroy(labels[j].gameObject);
    particles.RemoveAt(j);
    labels.RemoveAt(j);
    particleColors.RemoveAt(j);
    j--;
}

private void HandleFragmentation(int i, int j, Rigidbody2D rbA, Rigidbody2D rbB)
{
    float totalMass = rbA.mass + rbB.mass;
    Vector2 explosionCenter = (rbA.position + rbB.position) / 2f;
    Vector2 explosionVelocity = (rbA.velocity + rbB.velocity) / 2f;
    int fragmentCount = Random.Range(3, 6);
    int maxFragments = Mathf.FloorToInt(totalMass);
    fragmentCount = Mathf.Min(fragmentCount, maxFragments);
    float remainingMass = totalMass;

    Vector2[] directions = new Vector2[fragmentCount];
    float[] masses = new float[fragmentCount];
    Vector2[] velocities = new Vector2[fragmentCount];
    Vector2 totalOffsetMomentum = Vector2.zero;

    for (int fi = 0; fi < fragmentCount; fi++) {
        directions[fi] = Random.insideUnitCircle.normalized;
        masses[fi] = (fi < fragmentCount - 1) ? Mathf.Max(1f, Random.Range(1f, remainingMass - 1f * (fragmentCount - fi - 1))) : remainingMass;
        remainingMass -= masses[fi];
    }

    for (int fi = 0; fi < fragmentCount; fi++) {
        velocities[fi] = directions[fi] * (1f / Mathf.Sqrt(masses[fi]));
        totalOffsetMomentum += velocities[fi] * masses[fi];
    }

    Vector2 correction = totalOffsetMomentum / totalMass;
    float originalKineticEnergy = 0.5f * rbA.mass * rbA.velocity.sqrMagnitude + 0.5f * rbB.mass * rbB.velocity.sqrMagnitude;
    float fragmentKineticEnergy = 0f;
    for (int fi = 0; fi < fragmentCount; fi++) {
        Vector2 rawV = velocities[fi] - correction;
        fragmentKineticEnergy += 0.5f * masses[fi] * rawV.sqrMagnitude;
    }
    float energyScale = Mathf.Sqrt(originalKineticEnergy / Mathf.Max(0.0001f, fragmentKineticEnergy));

    for (int fi = 0; fi < fragmentCount && particles.Count < maxParticles; fi++) {
        Vector2 adjustedVelocity = explosionVelocity + (velocities[fi] - correction) * energyScale;
        GameObject frag = Instantiate(particlePrefab, explosionCenter + directions[fi] * 0.2f, Quaternion.identity);
        var fragRb = frag.GetComponent<Rigidbody2D>();
        fragRb.mass = masses[fi];
        fragRb.velocity = adjustedVelocity;
        frag.transform.localScale = Vector3.one * baseParticleScale * Mathf.Pow(masses[fi], 1f / 3f);
        var fragSr = frag.GetComponent<SpriteRenderer>();
        var fragColor = GetColorByMass(masses[fi]);
        if (fragSr != null) fragSr.color = fragColor;
        var fragLabel = Instantiate(labelPrefab, worldCanvas.transform).GetComponent<TMP_Text>();
        fragLabel.text = "";
        particles.Add(frag);
        labels.Add(fragLabel);
        particleColors.Add(fragColor);
        particleIDs.Add(nextParticleID++);
    }

    GameObject particleA = particles[i];
    GameObject labelA = labels[i].gameObject;
    int idA = i < particleIDs.Count ? particleIDs[i] : -1;
    int jIndex = j > i ? j - 1 : j;
    GameObject particleB = jIndex < particles.Count ? particles[jIndex] : null;
    GameObject labelB = jIndex < labels.Count ? labels[jIndex].gameObject : null;
    int idB = jIndex < particleIDs.Count ? particleIDs[jIndex] : -1;

    particles.RemoveAt(i);
    labels.RemoveAt(i);
    particleColors.RemoveAt(i);
    particleIDs.RemoveAt(i);
    Destroy(particleA);
    Destroy(labelA);

    if (jIndex < particles.Count)
    {
        particles.RemoveAt(jIndex);
        labels.RemoveAt(jIndex);
        particleColors.RemoveAt(jIndex);
        particleIDs.RemoveAt(jIndex);
        if (particleB != null) Destroy(particleB);
        if (labelB != null) Destroy(labelB);
    }

    collisionLog.Add($"{Time.time:F2}s: Fragment {idA} + {idB}{fragmentCount} particles");
}
    Rigidbody2D rbB = particles[j].GetComponent<Rigidbody2D>();
    if (rbB == null) return;

    Vector2 direction = rbB.position - rbA.position;
    float distance = direction.magnitude;
    if (distance < 0.1f) distance = 0.1f;

    float relativeVelocity = (rbA.velocity - rbB.velocity).magnitude;

    if (distance < mergeDistance)
    {
        if (relativeVelocity < 0.5f)
        {
            HandleMerge(i, j, rbA, rbB);
            return;
        }
        else if (relativeVelocity > 3f && rbA.mass >= 1f && rbB.mass >= 1f)
        {
            HandleFragmentation(i, j, rbA, rbB);
            return;
        }
    }

    if (!ignoreCutoff && distance > gravityCutoffDistance) return;

    Vector2 force = gravitationalConstant * (rbA.mass * rbB.mass / (distance * distance)) * direction.normalized;
    rbA.AddForce(force);
    rbB.AddForce(-force);
}
        for (int i = 0; i < particles.Count; i++)
        {
            Rigidbody2D rbA = particles[i].GetComponent<Rigidbody2D>();
            if (rbA == null) continue;

            for (int j = i + 1; j < particles.Count; j++)
            {
                Rigidbody2D rbB = particles[j].GetComponent<Rigidbody2D>();
                if (rbB == null) continue;

                Vector2 direction = rbB.position - rbA.position;
                float distance = direction.magnitude;
                if (distance < 0.1f) distance = 0.1f;

                float relativeVelocity = (rbA.velocity - rbB.velocity).magnitude;

                if (distance < mergeDistance)
                {
                    if (relativeVelocity < 0.5f)
                    {
                        float totalMass = rbA.mass + rbB.mass;
                        rbA.velocity = (rbA.velocity * rbA.mass + rbB.velocity * rbB.mass) / totalMass;
                        rbA.mass = totalMass;
                        int idA = i < particleIDs.Count ? particleIDs[i] : -1;
                        int idB = j < particleIDs.Count ? particleIDs[j] : -1;
                        collisionLog.Add($"{Time.time:F2}s: Merge {idA} + {idB}{idA}, NewMass={totalMass:F1}");

                        Destroy(particles[j]);
                        Destroy(labels[j].gameObject);
                        particles.RemoveAt(j);
                        labels.RemoveAt(j);
                        particleColors.RemoveAt(j);
                        j--;
                        continue;
                    }
                    else if (relativeVelocity > 3f && rbA.mass >= 1f && rbB.mass >= 1f)
                    {
                        float totalMass = rbA.mass + rbB.mass;
                        Vector2 explosionCenter = (rbA.position + rbB.position) / 2f;
                        Vector2 explosionVelocity = (rbA.velocity + rbB.velocity) / 2f;
                        int fragmentCount = Random.Range(3, 6);
                        int maxFragments = Mathf.FloorToInt(totalMass);
                        fragmentCount = Mathf.Min(fragmentCount, maxFragments);
                        float remainingMass = totalMass;

                        // 分裂後速度分配:完全動量守恆分裂模型
                        Vector2[] directions = new Vector2[fragmentCount];
                        float[] masses = new float[fragmentCount];
                        Vector2[] velocities = new Vector2[fragmentCount];

                        Vector2 totalOffsetMomentum = Vector2.zero;

                        for (int iFrag = 0; iFrag < fragmentCount; iFrag++) {
                            directions[iFrag] = Random.insideUnitCircle.normalized;
                            masses[iFrag] = (iFrag < fragmentCount - 1) ? Mathf.Max(1f, Random.Range(1f, remainingMass - 1f * (fragmentCount - iFrag - 1))) : remainingMass;
                            remainingMass -= masses[iFrag];
                        }

                        for (int iFrag = 0; iFrag < fragmentCount; iFrag++) {
                            velocities[iFrag] = directions[iFrag] * (1f / Mathf.Sqrt(masses[iFrag]));
                            totalOffsetMomentum += velocities[iFrag] * masses[iFrag];
                        }

                        // 平衡總動量偏移:全部減掉平均動量
                            Vector2 correction = totalOffsetMomentum / totalMass;

                            // 加入能量守恆:修正動能總和
                            float originalKineticEnergy = 0.5f * rbA.mass * rbA.velocity.sqrMagnitude + 0.5f * rbB.mass * rbB.velocity.sqrMagnitude;
                            float fragmentKineticEnergy = 0f;
                            for (int fi = 0; fi < fragmentCount; fi++) {
                            Vector2 rawV = velocities[fi] - correction;
                            fragmentKineticEnergy += 0.5f * masses[fi] * rawV.sqrMagnitude;
                            }
                            float energyScale = Mathf.Sqrt(originalKineticEnergy / Mathf.Max(0.0001f, fragmentKineticEnergy));

                            for (int fi = 0; fi < fragmentCount && particles.Count < maxParticles; fi++) {
    Vector2 adjustedVelocity = explosionVelocity + (velocities[fi] - correction) * energyScale;

    GameObject frag = Instantiate(particlePrefab, explosionCenter + directions[fi] * 0.2f, Quaternion.identity);
    var fragRb = frag.GetComponent<Rigidbody2D>();
    fragRb.mass = masses[fi];
    fragRb.velocity = adjustedVelocity;
    frag.transform.localScale = Vector3.one * baseParticleScale * Mathf.Pow(masses[fi], 1f / 3f);

    var fragSr = frag.GetComponent<SpriteRenderer>();
    var fragColor = GetColorByMass(masses[fi]);
    if (fragSr != null) fragSr.color = fragColor;

    var fragLabel = Instantiate(labelPrefab, worldCanvas.transform).GetComponent<TMP_Text>();
    fragLabel.text = "";

    particles.Add(frag);
    labels.Add(fragLabel);
    particleColors.Add(fragColor);
    particleIDs.Add(nextParticleID++);
}

                        }

                        // 儲存刪除前的對象參考
                        GameObject particleA = particles[i];
                        GameObject labelA = labels[i].gameObject;
                        int idA = i < particleIDs.Count ? particleIDs[i] : -1;

                        int jIndex = j > i ? j - 1 : j;
                        GameObject particleB = jIndex < particles.Count ? particles[jIndex] : null;
                        GameObject labelB = jIndex < labels.Count ? labels[jIndex].gameObject : null;
                        int idB = jIndex < particleIDs.Count ? particleIDs[jIndex] : -1;

                        // 刪除原始粒子
                        particles.RemoveAt(i);
                        labels.RemoveAt(i);
                        particleColors.RemoveAt(i);
                        particleIDs.RemoveAt(i);
                        Destroy(particleA);
                        Destroy(labelA);

                        // 刪除另一個粒子(保護性檢查)
                        if (jIndex < particles.Count)
                        {
                            particles.RemoveAt(jIndex);
                            labels.RemoveAt(jIndex);
                            particleColors.RemoveAt(jIndex);
                            particleIDs.RemoveAt(jIndex);
                            if (particleB != null) Destroy(particleB);
                            if (labelB != null) Destroy(labelB);
                        }

                        collisionLog.Add($"{Time.time:F2}s: Fragment {idA} + {idB}{fragmentCount} particles");

                        i--;
                        break;
                    }
                    else
                    {
                        // 中等速度會正常碰撞(什麼都不做)
                    }
                }

                if (!ignoreCutoff && distance > gravityCutoffDistance) continue;

                Vector2 force = gravitationalConstant * (rbA.mass * rbB.mass / (distance * distance)) * direction.normalized;
                rbA.AddForce(force);
                rbB.AddForce(-force);

        }
    }    

    public void GenerateParticles()
    {
        Vector2 center = transform.position;
        minBounds = center - spawnAreaSize / 2f;
        maxBounds = center + spawnAreaSize / 2f;

        foreach (var p in particles) Destroy(p);
        foreach (var l in labels) Destroy(l.gameObject);
        particles.Clear();
        labels.Clear();
        particleIDs.Clear();
        particleColors.Clear();

        int count = int.TryParse(particleCountInput.text, out int val) ? val : 10;
        float maxSpeed = float.TryParse(speedLimitInput.text, out float fval) ? fval : 5f;

        for (int i = 0; i < count && particles.Count < maxParticles; i++)
        {
            Vector2 randomPos = new Vector2(
                Random.Range(minBounds.x, maxBounds.x),
                Random.Range(minBounds.y, maxBounds.y));

            Vector2 velocity = Random.insideUnitCircle.normalized * Random.Range(0, maxSpeed);
            CreateParticleInstance(randomPos, velocity, 1f);
        }

        logTimer = 0f;
        string timestamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss");
        logFilePath = Path.Combine(Application.persistentDataPath, $"particle_log_{timestamp}.txt");
        File.WriteAllText(logFilePath, "Time,Index,PositionX,PositionY,VelocityX,VelocityY");
        if (logOutputText != null) logOutputText.text = "";
    }

    private void CreateParticleInstance(Vector2 position, Vector2 velocity, float mass)
    {
        GameObject particle = Instantiate(particlePrefab, position, Quaternion.identity);
        Rigidbody2D rb = particle.GetComponent<Rigidbody2D>();
        if (rb != null)
        {
            rb.velocity = velocity;
            rb.mass = mass;
            particle.transform.localScale = Vector3.one * baseParticleScale * Mathf.Pow(mass, 1f / 3f);
            if (particlePhysicsMaterial != null) rb.sharedMaterial = particlePhysicsMaterial;
        }

        SpriteRenderer sr = particle.GetComponent<SpriteRenderer>();
        Color massColor = GetColorByMass(mass);
        if (sr != null)
            sr.color = massColor;
        particleColors.Add(massColor);

        GameObject labelGO = Instantiate(labelPrefab, worldCanvas.transform);
        TMP_Text label = labelGO.GetComponent<TMP_Text>();
        label.text = "";

        particles.Add(particle);
        labels.Add(label);
        particleIDs.Add(nextParticleID++);
    }

    private void ClampParticlesToBounds()
    {
        for (int i = 0; i < particles.Count; i++)
        {
            if (particles[i] == null) continue;
            Rigidbody2D rb = particles[i].GetComponent<Rigidbody2D>();
            if (rb == null) continue;

            Vector2 clampedPos = new Vector2(
                Mathf.Clamp(rb.position.x, minBounds.x, maxBounds.x),
                Mathf.Clamp(rb.position.y, minBounds.y, maxBounds.y));

            if ((clampedPos - rb.position).sqrMagnitude > 0f)
            {
                rb.position = clampedPos;
                rb.velocity = -rb.velocity * 0.5f; // 模擬反彈效果並減速
            }
        }
    }

    private void LogParticleStates()
    {
        System.Text.StringBuilder screenOutput = new System.Text.StringBuilder();
        System.Text.StringBuilder fileOutput = new System.Text.StringBuilder();

        for (int i = 0; i < particles.Count; i++)
        {
            GameObject p = particles[i];
            if (p != null)
            {
                Rigidbody2D rb = p.GetComponent<Rigidbody2D>();
                if (rb != null)
                {
                    float t = Time.time;
                    Vector2 pos = rb.position;
                    Vector2 vel = rb.velocity;
                    screenOutput.AppendLine($"[{t:F2}s] Particle {i}: Pos=({pos.x:F2},{pos.y:F2}), Vel=({vel.x:F2},{vel.y:F2})");
                    fileOutput.AppendLine($"{t:F2},{i},{pos.x:F2},{pos.y:F2},{vel.x:F2},{vel.y:F2}");
                }
            }
        }
        if (logOutputText != null) logOutputText.text = screenOutput.ToString();
        File.AppendAllText(logFilePath, fileOutput.ToString());
        if (collisionLog.Count > 0)
        {
            File.AppendAllLines(logFilePath, collisionLog);
            collisionLog.Clear();
        }
    }

    private void LogTotalKineticEnergy()
    {
        float totalEnergy = 0f;
        foreach (var p in particles)
        {
            if (p == null) continue;
            Rigidbody2D rb = p.GetComponent<Rigidbody2D>();
            if (rb != null)
                totalEnergy += 0.5f * rb.mass * rb.velocity.sqrMagnitude;
        }
        if (energyValueText != null)
            energyValueText.text = $"目前總動能:{totalEnergy:F2}";

        energyHistory.Add(totalEnergy);

            if (energyHistory.Count > maxEnergyBars)
            energyHistory.RemoveAt(0);

        UpdateEnergyGraph();
    }

    private void UpdateEnergyGraph()
    {
        if (energyGraphContainer == null || energyBarPrefab == null) return;

        foreach (Transform child in energyGraphContainer)
        {
            Destroy(child.gameObject);
        }

        float graphHeight = energyGraphContainer.rect.height;
        float graphWidth = energyGraphContainer.rect.width;
        float maxEnergy = Mathf.Max(1f, Mathf.Max(energyHistory.ToArray()));
        float barWidth = graphWidth / Mathf.Max(1, energyHistory.Count);

        for (int i = 0; i < energyHistory.Count; i++)
        {
            float normalizedHeight = energyHistory[i] / maxEnergy;
            GameObject bar = Instantiate(energyBarPrefab, energyGraphContainer);
            RectTransform rt = bar.GetComponent<RectTransform>();
            rt.anchorMin = new Vector2(0, 0);
            rt.anchorMax = new Vector2(0, 0);
            rt.pivot = new Vector2(0, 0);
            rt.anchoredPosition = new Vector2(i * barWidth, 0);
            rt.sizeDelta = new Vector2(barWidth - 1f, normalizedHeight * graphHeight);
        }
    }
   }
0
Subscribe to my newsletter

Read articles from 郭俊鑫 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

郭俊鑫
郭俊鑫