[Unity] 以任意角度取圓周上座標之原理與語法

因為要在 Unity 上做一個能在圓周上隨機生成物件的功能,回去研究了一下關於圓與角度的數學。

原理

一、我們會先定義好圓心和半徑,這邊的圓心為 (0, 0)、C = 半徑、θ 為角度,可繪製出以下圖形:

(B, A) 就是我們要求的座標。

二、帶入三角函數公式,其實這就是邊的比例。


先取 座標,我們已知 C = 半徑,為了拿到 B 值,要用 C 乘

已知 等於 ,所以等同

去分母後就是 B 值。


座標同理,只不過改成 C 乘

一樣去分母…


語法

知道為什麼需要乘 後,接下來就是用腳本來實現他。

一、定義圓心與半徑。

var center = new Vector3(0, 0, 0);
var radius = 3;

二、計算弧度,因為 Unity 自帶的 Mathf.Cos 和 Mathf.Sin 只能輸入弧度並非角度,所以需要事先進行換算。( = 弧度、 = 角度)

以 20 度為例:

var rad = 20 * Mathf.PI / 180;

三、計算 XY 座標,如圓心不在 (0, 0) 時需要加上圓心座標。

var x = center.x + radius * Mathf.Cos(rad);
var y = center.y + radius * Mathf.Sin(rad);

四、最後加入物件生成後會像這樣,指定於角度 20 度座標位置生成一個 Ball 物件:

public GameObject Ball;

private void Start()
{
    var center = new Vector3(0, 0, 0);
    var radius = 3;
    var rad = 20 * Mathf.PI / 180;
    var x = center.x + radius * Mathf.Cos(rad);
    var y = center.y + radius * Mathf.Sin(rad);
    Ball.transform.position = new Vector3(x, y, center.z);
    Instantiate(Ball);
}

Unity 預覽,紅點為圓心,白點為生成的 Ball。

五、實際運用方面,我們可以用迴圈達成「在圓周上每 20 度生成一個物件」的目的。

public GameObject Ball;

private void Start()
{
    var center = new Vector3(0, 0, 0);
    var radius = 3;
    for (int i = 0; i < 360; i += 20)
    {
        var rad = i * Mathf.PI / 180;
        var x = center.x + radius * Mathf.Cos(rad);
        var y = center.y + radius * Mathf.Sin(rad);
        Ball.transform.position = new Vector3(x, y, center.z);
        Instantiate(Ball);
    }
}

輸出結果

[Csharp] MongoDB 5.0 資料庫交易 (Transaction) 語法筆記

在資料庫中有一個術語為 ACID,它其實是資料庫管理系統為保證數據可靠的四個特性的縮寫,分別為「原子性 Atomicity」、「一致性 Consistency」、「隔離 Isolation」與「永久性 Durability」。而我們現在要用到的「交易 Transaction」功能主要是原子性 Atomicity 的應用,簡單一句話來說明這個特性就是「全有,或全無」。

為什麼要使用交易?你可以嘗試用任何程式語言寫一個迴圈,這個迴圈會逐步將多筆資料塞入陣列,且迴圈中間發生了寫入錯誤 (例如:Index 無效) 造成執行中斷,而如果你的這個陣列內容是會直接序列化的話 (例如:Unity 的 ScriptableObject),就會發現裡面會存在程式中斷前已寫入的不完整資料。那假設我們要再次嘗試寫入資料的話要怎麼做?可能做法就是直接 Clear 陣列重來,否則就需要先讀取陣列內容以防止重複資料。資料庫的寫入或更動資料其實也是如此,如果沒有交易,好好的寫入作業被中斷就會造成數據不完整的問題,所以才需要交易功能的原子性來解決它。

「全有,或全無」的特性可以用一個範例來舉例:

假設我們現在有一個空資料表,現在要一次建立多筆產品資料 Product,主鍵為 ID 且不可重複,但這邊假設提供的內容有重複的可能性。

一、建立產品的資料模型

public class Product {
    [BsonId]
    [BsonRepresentation(BsonType.Int32)]
    public int Id { get; set; }
    public string Name { get; set; }
    public float Price { get; set; }
}

二、建立 MongoDB 資料庫連線,並先取好資料庫與集合 Collection。

var connectionString = "mongodb://admin:0000@127.0.0.1:27017";
var client = new MongoClient(connectionString);
var collection = client.GetDatabase("MyStore").GetCollection<Product>("Product");

三、完成迴圈寫入與交易功能實作。

public async Task CreateProducts (Product[] products) {
    // 啟動會話
    using (var session = await client.StartSessionAsync())
    {
        // 開始交易
        session.StartTransaction();

        try
        {
            // 執行寫入
            foreach(var e in products)
            {
                // 寫入資料,可能會出錯
                await collection.InsertOneAsync(session, e);
            }

            // 無中斷視為成功,確認交易
            await session.CommitTransactionAsync();
        }
        catch (Exception)
        {
            // 攔截錯誤,將已寫入的不完整資料全部回滾至寫入前狀態
            await session.AbortTransactionAsync();
        }
    }
}

在任何可能發生資料寫入問題的地方基本上都需要實作交易,僅查詢的話不需要交易。

來說明一下這支程式的原理:

在需要交易的區塊必須先啟動一個會話 Session,它會給你一個 IClientSessionHandle。這邊我們可以用一個很簡單的 using 把區塊包起來,在離開區塊時會自動 Dispose 以防止記憶體洩漏 Memory Leak。

client.StartSessionAsync() 的 client 是最前面建立資料庫連線 new MongoClient() 的物件,需要用它來啟動會話。這邊我有看到有人在 ASP.NET 中用 DI 來實作,只不過因為我有用到多資料庫的關係不好實作,就先暫時不講那個方法。

using (var session = await client.StartSessionAsync()) {

    // TODO
}

啟動會話後,就可以在會話有效區域內啟動交易,在上面這個區塊內的最前面加入:

session.StartTransaction();

接下來加入最主要的迴圈寫入程式:

foreach(var e in products)
{
	await collection.InsertOneAsync(session, e);
}

這邊要注意 InsertOneAsync 方法內的參數,平常沒有交易的寫法是直接塞入 Object,但現在因為要使用交易功能則必須在第一個參數寫入會話的 session。印象中這版的 MongoDB C# 所有會關係刪改的方法應該都有支援交易 (有誤請指正,這個我不太確定)。

// 無交易
await collection.InsertOneAsync(e);

// 有交易
await collection.InsertOneAsync(session, e);

前面我們有提到,假設在執行 Insert 時會發生錯誤,為了不使程式直接 Crash,需要用 try-catch 攔截它,需要改寫成下面這樣:

try
{
	foreach(var e in products)
	{
		await collection.InsertOneAsync(session, e);
	}
}
catch (Exception)
{
	// 攔截錯誤
}

最後加入最關鍵的兩個方法:「確認」與「回滾」。當呼叫確認時,在交易中所有的刪改都會真正的寫入資料庫,反過來說,如果你沒有呼叫確認,你前面的所作所為就是做白工;而當呼叫回滾時,交易中刪改皆會被回滾至作業開始前的狀態。

有沒有發現這就是原子性的「全有,或全無」呢?

// 確認
await session.CommitTransactionAsync();

// 回滾
await session.AbortTransactionAsync();

插入確認與回滾方法後的內容長這樣:

try
{
	foreach(var e in products)
	{
		await collection.InsertOneAsync(session, e);
	}
	await session.CommitTransactionAsync();
}
catch (Exception)
{
	await session.AbortTransactionAsync();
}

以上。

參考資料

[Unity] 方法帶入 Action 參數,讓程式更易讀且更靈活

平常我們的程式都是由上到下按照順序執行,只要沒放錯位置,後面的功能都有辦法讀到前面方法輸出的結果。

但是如果碰到非同步 Async、協程 Coroutine 之類的會大幅改變程式的執行順序時,原本作法就不管用。在這篇文章中要教你如何用「Action」來解決這個問題,而不是用一個迴圈來頻繁檢查前面的作業是否已完成,是個能讓程式更靈活、更易讀的寫法。

Action 是 System 內的功能,它有分成無帶參數有帶參數兩種用法。它在調用常會使用 Lambda 運算式,初次使用的開發者們可能會被這個外星文嚇跑,但其實這個東西並沒有你想得那麼複雜,其實它的邏輯很簡單!

基礎語法一

Action 在其他同樣是 C# 語言的框架中都能用,我們這邊就以 Unity 來說明。

觀念

Action 是一個將「方法」作為「參數」進行傳遞的功能

以往我們對參數的認知都是「死」的,比方說我輸入整數 1,這個參數在方法中就只能做運算或再丟給其他方法使用,它不會為這個方法帶來新的東西。

那我們用 Action 來將方法變成參數,輸入到這個 RunJob 中如何?

public void RunJob(System.Action onComplete)
{
    Debug.Log("作業中...");
    onComplete();
}

你可以發現我們輸入的 onComplete 參數居然可以使用調用方法的寫法來呼叫!這代表我們能在這個方法中的任意位置調用 onComplete,能依照你給的東西來改變程式最終的結果。

那麼我們要怎麼把方法當參數輸入進去呢?

一、再寫一個方法

目前我們是不額外帶參數的 Action,所以我們可以再寫一個無參數的方法 PrintTips,並再調用 RunJob 時將 PrintTips 直接當參數帶入,僅名稱就好,不可加上括弧。
有括弧就是方法調用了,語法錯誤 ❌⛔

private void Start()
{
    RunJob(PrintTips);
}
public void PrintTips()
{
    Debug.Log("初次登入時請記得修改密碼!");
    Debug.Log("不能忘喔!");
}

二、Lambda

Lambda 的規則說起來麻煩,就不解釋了。我們這邊聚焦在要怎麼直接上手用它。請參考以下:

RunJob(() => {
    Debug.Log("初次登入時請記得修改密碼!");
    Debug.Log("不能忘喔!");
});

首先因為現在沒有讓 Action 也帶入參數,所以要用 () 空括弧來代表 => 後的方法內容,大括弧中的內容就是你的自由發揮空間。

如果裡面只有一行程式碼,還可以省略掉大括弧與結尾分號:

RunJob(() => Debug.Log("初次登入時請記得修改密碼!"));

兩種方式的輸出結果都一樣:

實作練習一

請使用一個無帶參數的 Action 並搭配方法,在開始時印出「開始任務…」後過三秒,再經由輸入的 Action 中打印「任務完成!」字串。

參考解答

using System.Collections;
using UnityEngine;
public class ActionTutorial : MonoBehaviour
{
    private void Start()
    {
        RunJob(() => {
            Debug.Log("任務完成!");
        });
    }
    public void RunJob(System.Action onComplete)
    {
        Debug.Log("開始任務...");
        StartCoroutine(JobTask(onComplete));
    }
    private IEnumerator JobTask(System.Action onComplete)
    {
        yield return new WaitForSecondsRealtime(3);
        onComplete();
    }
}

基礎語法二

前面我們講過無帶參數的用法了,現在來介紹有帶參數要怎麼寫。

在寫方法的時候,在定義的 Action 類別後加上 <類別>。例如你想它帶一個整數,那就是 System.Action<int>,如果要帶布林值,那就寫 System.Action<bool>

最後在調用 Action 時帶入你定義的型別資料,就可以成功帶入囉!

public void RunJob(System.Action<int> onComplete)
{
    Debug.Log("開始隨機取號...");
    onComplete(Random.Range(0, 1000));
}

如果想要帶入更多筆資料,除了用 Struct、Class 之外,也可以這樣做:

public void RunJob(System.Action<int, string> onComplete)
{
    Debug.Log("開始隨機取號...");
    onComplete(Random.Range(0, 1000), "成功的男人背後都有一條脊椎");
}

用逗點分隔就能加參數,很有趣吧?(最多可以加 16 個)

那要怎麼接收帶入的參數呢?

一、用另一個方法

假設 RunJob 的 Action 有帶入一個整數,那就在輸入的方法中加入一個整數參數即可。

private void Start()
{
    RunJob(PrintTips);
}
public void PrintTips(int value)
{
    Debug.Log("你抽到的號碼是" + value);
    Debug.Log("恭喜你囉!");
}

多參數的寫法如下,以此類推。

public void PrintTips(int value, string data)
{
    Debug.Log("你抽到的號碼是" + value);
    Debug.Log("恭喜你囉!");
    Debug.Log(data);
}

二、Lambda

如果你僅帶入一個值,那可以用 xyz、abc 或你隨便亂取的名稱來做為暫存用變數,最後就能在定義的區塊隨意調用它。

RunJob(x => {
    Debug.Log("你抽到的號碼是" + x);
    Debug.Log("恭喜你囉!");
});

多參數的寫法如下,需用括弧包住多筆參數,以此類推。

RunJob((x, y) => {
    Debug.Log("你抽到的號碼是" + x);
    Debug.Log("恭喜你囉!");
    Debug.Log(y);
});

兩種方法的輸出結果都一樣:

實作練習二

請用有帶參數的 Action 並搭配方法,輸出一個範圍 -1000~1000 隨機取值的 XY 二維座標,並判斷這個座標是否在地底(看 Y 是否小於 0)。

參考解答

using UnityEngine;
public class ActionTutorial : MonoBehaviour
{
    private void Start()
    {
        RunJob((a, b) => {
            Debug.Log("座標: " + a);
            Debug.Log("是否在地底: " + b);
        });
    }
    public void RunJob(System.Action<Vector2, bool> onComplete)
    {
        int xPos = Random.Range(-1000, 1000);
        int yPos = Random.Range(-1000, 1000);
        bool isUnderGround = yPos < 0;
        onComplete(new Vector2(xPos, yPos), isUnderGround);
    }
}

[Unity] 檔案/資料夾路徑字串組合方法 Path.Combine

最近有機會接觸到用 C# 來操作 StreamingAssets 資料的功能,所以會大量接觸「檔案路徑」的資料處理。然而我對於必須用 A + "/" + B + "/" + C 來組合路徑的寫法非常反感,所以提出了幾個解決方案來避免掉每次都得手動組合路徑的情況。

目前有兩種方法可以取代手動組合的情況:

System.IO.Path.Combine

這個是 System.IO 下的方法,它的好處是會自動偵測路徑是否合法,與自動轉換相對與絕對路徑並重新組合。但是當他使用在我的專案中出現了輸出字串空白的問題,猜測是其中作為路徑的字串參數會導致此方法無法正確判定,所以它讓我寫出了第二種解決方法 PathTool.Combine。

Path.Combine 的用法很簡單,假如我有「Application.streamingAssetsPath」、「Language」和「Config.csv」,組合寫法如下:

using UnityEngine;
public class PathScript : MonoBehaviour
{
    private void Start()
    {
        Debug.Log(GetConfigPath());
    }
    public string GetConfigPath()
    {
        string path = System.IO.Path.Combine(Application.streamingAssetsPath, "Language", "Config.csv");
        return path;
    }
}

輸出後會獲得:

C:/Project/Project_Text/Assets/StreamingAssets\Language\Config.csv

你可能會介意斜線方向,但以一般用途來說不影響之後的操作。有需要再自行用 String 的 Replace 功能來取代。

自製 PathTool.Combine

為了解決我遇到的輸出空白問題,就自行寫了一個單純用 for 迴圈組合字串、沒有做任何合法驗證的方法。這邊有個 params 關鍵字還挺有趣,他可以讓後面接的陣列變成參數化的寫法。

public class PathTool
 {
     public static string Combine(params string[] paths)
     {
         string result = "";
         for(int i = 0; i < paths.Length; i++)
         {
             result += paths[i];
             if (i + 1 != paths.Length)
             {
                 result += "/";
             }
         }
         return result;
     }
 }

至於用法就和 Path.Combine 一樣,我就不貼完整腳本了:

PathTool.Combine(Application.streamingAssetsPath, "Language", "Config.csv");

以上就是這次的 Unity 筆記,有時候完整且複雜的功能會不好用,最單純最簡單的寫法反而會比較貼近人心。如有任何疑問歡迎在留言版提出!

[Unity] Debug.Log 其實支援 Rich Text,可調整粗、斜體、大小與顏色

之前用到 DOTween 的時候就已經很好奇了,為甚麼他的 Log 可以有顏色和粗體?如果我們的 Log 都能用醒目提示來區分功能,那就能省下很多尋找的時間。

今天看到一支老外的影片,他們稱我們在找 Log 時的低效率情況為「Scrolling Blindness」,簡單解釋就是在滾動 Log 時,所有文字都是單一樣式,很容易眼花。

其實在 Debug.Log 的官方文檔中就有藏著一句「You can also use Rich Text markup.」,代表 Console 是能夠正確解讀 Rich Text 語法的,DOTween 的 Log 會有顏色就是這樣來的。

Rich Text 支援語法

Rich Text 的寫法跟 HTML 基本一樣,都是由一組 <>Someting</> 包覆起來的標籤語言。Debug.Log 支援的語法和範例如下表格:

樣式語法範例備註
粗體<b>Chicken Attack</b>
斜體<i>OK Boomer</i>
大小<size=23>OMG</size>等於符號前後不可有空白
數值 23 剛好可以把 UnityEngine.Debug:Log(Object) 隱藏
顏色<color=#ff000077>Joshua?</color>等於符號前後不可有空白
數值可以填入 #rgb、#rgba 或 #rrggbb、#rrggbbaa
也可以直接填入顏色名稱,支援字詞請參考 表格

結果預覽

將以下四行寫入 Start() 中進行測試:

真心認為 Debug.Log 能支援 Rich Text 真的太好了,平常開發遊戲時要花時間在一坨 Log 中找需要的資訊的情況,工程師們並不樂見。如果能在下 Log 時多花幾秒鐘上點樣式,就算只是 Size 也好,對於開發順暢度應該會有顯著提升。