在前端直接將檔案上傳到AWS S3

在自己架設的伺服器中,如果要把檔案上傳到AWS S3,一種常見的做法是先將檔案上傳到自身伺服器然後再上傳到AWS S3,但是這樣太耗費資源了。
幸好AWS S3有方式可以讓你在前端直接將檔案上傳到S3中,以下說明如何設定前端的HTML,讓檔案直接上傳。

首先你要有可以存取BucketAWSAccessKeyIdAWSSecretAccessKey

以下資料皆來自https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html

AWSAccessKeyId AKIAIOSFODNN7EXAMPLE
AWSSecretAccessKey wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Bucket sigv4examplebucket

HTML FORM

以下是HTML Form的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>

<!-- 將form的 action ,設為 action="https://<你的Bucket名稱>.s3.amazonaws.com/" , 如下-->
<form action="http://sigv4examplebucket.s3.amazonaws.com/" method="post" enctype="multipart/form-data">
Key to upload:
<!-- 這個 key 將設定 S3 Object Key, 例如設為: user/user1/myfile.csv,之後在AWS S3上面看到的就是 user/user1/myfile.csv -->
<input type="input" name="key" value="user/user1/myfile.csv" /><br />
<!-- 這個 acl 將設定 S3 ACL, 如設為:public-read 表示可以被公開讀取,也可以設為 private ,表示是私有的-->
<input type="hidden" name="acl" value="public-read" />
<!-- 這個 success_action_redirect 將設定 上傳成功之後,要導向到哪個網頁,如設為:http://localhost:8080/uploaded.html -->
<input type="hidden" name="success_action_redirect" value="http://localhost:8080/uploaded.html" />
Content-Type:
<!-- 這個 Content-Type 將限定要上傳的檔案類型,可以不設定 -->
<!-- <input type="input" name="Content-Type" value="image/jpeg" /><br /> -->
<!-- 這個 x-amz-meta-uuid 應由後端產生一組隨機的字串,讓AWS S3做驗證 -->
<input type="hidden" name="x-amz-meta-uuid" value="14365123651274" />
<!-- 這個 x-amz-server-side-encryption 說明使用哪種加密方式,例如 AES256 -->
<input type="hidden" name="x-amz-server-side-encryption" value="AES256" />

<!-- 這個 X-Amz-Credential 由 <AWSAccessKeyId>/<日期>/<Bucket-Region>/<RegionService>/<Signing> 組成,RegionService和Signing是固定的就是s3和aws4_request-->
<input type="text" name="X-Amz-Credential" value="AKIAIOSFODNN7EXAMPLE/20151229/us-east-1/s3/aws4_request" />
<!-- 這個 X-Amz-Algorithm說明建立這個Request的簽名使用哪種演算法,例如 AWS4-HMAC-SHA256 -->
<input type="text" name="X-Amz-Algorithm" value="AWS4-HMAC-SHA256" />
<!-- 這個 X-Amz-Date 說明建立這個Request的簽名的日期,為可選的,需要為 ISO 8601 basic format (YYYYMMDD'T'HHMMSS'Z') 格式 -->
<input type="text" name="X-Amz-Date" value="20151229T000000Z" />

Tags for File:
<!-- 這個 x-amz-meta-tag 是可選的 -->
<!-- <input type="input" name="x-amz-meta-tag" value="" /><br /> -->
<!-- 這個 Policy 為Base64編碼的字串,在下面說明 -->
<input type="hidden" name="Policy" value='<Base64-encoded policy string>' />
<!-- 這個 X-Amz-Signature 是簽名,包含AWSSecretAccessKey的值,應由後端產生,在下面說明 -->
<input type="hidden" name="X-Amz-Signature" value="<Signature-value>" />
File:
<input type="file" name="file" /> <br />
<input type="submit" name="submit" value="Upload to Amazon S3" />
</form>

</html>

Base64-encoded policy string

Policy 應由後端產生,他是一個JSON物件,以下是一個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{ "expiration": "2015-12-30T12:00:00.000Z",
"conditions": [
{"bucket": "sigv4examplebucket"},
["starts-with", "$key", "user/user1/"],
{"acl": "public-read"},
{"success_action_redirect": "http://localhost:8080/uploaded.html"},
// ["starts-with", "$Content-Type", "image/"],
{"x-amz-meta-uuid": "14365123651274"},
{"x-amz-server-side-encryption": "AES256"},
// ["starts-with", "$x-amz-meta-tag", ""],
{"x-amz-credential": "AKIAIOSFODNN7EXAMPLE/20151229/us-east-1/s3/aws4_request"},
{"x-amz-algorithm": "AWS4-HMAC-SHA256"},
{"x-amz-date": "20151229T000000Z" }
]
}
expiration 表示這個上傳的Request可用的期限
conditions 設定條件,在檔案上傳前AWS S3會檢查一下,是否和HTML Form中的相符合
bucket Bucket 名稱,此範例為 sigv4examplebucket
“starts-with”, “$key” 要放在這個Bucket的 Prefix,此範例為user/user1/ ,在HTML Form中的 key 需要符合在此設定的前綴祠才可以,如 user/user1/myfile.csv
acl 設定 S3 ACL, 如設為:public-read 表示可以被公開讀取,也可以設為 private ,表示是私有的
success_action_redirect 上傳成功之後,要導向到哪個網頁,如設為:http://localhost:8080/uploaded.html
x-amz-meta-uuid 後端產生的UUID
x-amz-server-side-encryption 說明使用哪種加密方式, AES256
“starts-with”, “$Content-Type” 上傳的 content type,可以不設定
x-amz-credential /<日期>/// 組成,RegionService和Signing是固定的就是s3和aws4_request
x-amz-algorithm 說明建立這個Request的簽名使用哪種演算法,例如 AWS4-HMAC-SHA256
x-amz-date 建立這個Request的簽名的日期,為可選的,需要為 ISO 8601 basic format (YYYYMMDD’T’HHMMSS’Z’) 格式

注意,如果你在HTML Form中沒有設定某些參數,那麼在Policy中也不要設定,例如我在HTML Form中沒有設定 ["starts-with", "$Content-Type", "image/"]["starts-with", "$x-amz-meta-tag", ""],那麼在 Policy中也不要設定。

之後由後端使用Base64將這段字串編碼,這個Base64字串編碼就是要放在 HTML Form 中的 Policy,以下是Java產生的範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JSONObject j = new JSONObject();

j.put("expiration", "2015-12-30T12:00:00.000Z");
j.put("conditions",
new JSONArray()
.put(new JSONObject().put("bucket", "sigv4examplebucket"))
.put(new JSONArray().put("starts-with").put("$key").put("user/user1/"))
.put(new JSONObject().put("acl", "private"))
.put(new JSONObject().put("success_action_redirect", "http://localhost:8080"))
.put(new JSONObject().put("x-amz-meta-uuid", "14365123651274"))
.put(new JSONObject().put("x-amz-server-side-encryption", "AES256"))
.put(new JSONObject().put("x-amz-credential", "AKIAIOSFODNN7EXAMPLE/20151229/us-east-1/s3/aws4_request"))
.put(new JSONObject().put("x-amz-algorithm", "AWS4-HMAC-SHA256"))
.put(new JSONObject().put("x-amz-date", "20151229T000000Z"))
);
// 這個 Policy 就是要放在 HTML Form 中的 Policy
String policy = Base64.getEncoder().encodeToString(j.toString().getBytes());

Signature-value

最後產生 Signature ,Java 可以直接使用有人提供的產生方式,來自 https://gist.github.com/phstudy/3523576726d74a0410f8

1
2
3
4
5
6
7
8
9
10
// 在 AccessSecretKey前面要加上 AWS4
String accessSecretKey = "AWS4" + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
String date = "20151229";
String region = "us-east-1";
String regionService = "s3";
String signing = "aws4_request";
String stringToSign = policy;

// 這個 signature 字串,就是要放在 HTML Form 中的 X-Amz-Signature
String signature = getSignatureV4(accessSecretKey, date, region, regionService, signing, stringToSign);

以上動作完成之後,還需要為你的Bucket設定 Cross-origin resource sharing (CORS)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"POST",
"GET",
"PUT"
],
"AllowedOrigins": [
"http://www.mywebsite.com/"
],
"ExposeHeaders": []
}
]

Bucket沒有設定Cross-origin resource sharing (CORS)會出現 CORS policy 錯誤

1
Access to XMLHttpRequest at 'https://<你的bucket>.s3.amazonaws.com/' from origin 'https://你的網站.com ' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

如果是還在測試開發的話,可以先將Chrome的web-security關閉,Mac使用以下指令,其他設定可以參考: disable-same-origin-policy-in-chrome

1
open -n -a "Google Chrome" --args --user-data-dir=/tmp/temp_chrome_user_data_dir http://localhost:8080/ --disable-web-security

Reference:

2D樞紐關節 (Hinge Joint 2D)

2D樞紐/鉸鏈關節(Hinge Joint 2D) 是 Unity 中的一個 2D 物理組件。

  • 它可以讓遊戲物件(GameObject)圍繞一個特定的點旋轉
  • 這個關節可以讓兩個點重疊,這兩個點可以是
    • 兩個2D剛體(Rigidbody 2D)物件,或是
    • 一個2D剛體物件另一個為一個世界空間中固定的點。若要連接到世界空間中固定的點可以透過將 Connected Rigidbody 設為 None
  • 這個關節可以向連接的兩個點施加一個線性力(linear force)
  • 通常用於模擬門、槓桿或鐘擺等旋轉機制。

2D樞紐/鉸鏈關節(Hinge Joint 2D)具有三個約束條件,這些條件都是可選的

  1. 保持兩個2D剛體(Rigidbody 2D)物件上的兩個錨點(anchor point)之間的相對線性距離(relative linear distance)
    • 此約束確保錨點之間的距離保持不變。
  2. 保持兩個2D剛體(Rigidbody 2D)遊戲物件上的兩個錨點之間的角速度(angular speed)
    • 可以使用Maximum Motor Force屬性限制最大扭矩(maximum torque)來限制角速度。
  3. 保持角度(angle)在指定的弧度(arc)範圍內
    • 此約束確保角度在一定範圍內。

可以使用這個關節來建構需要像旋轉樞軸行為的物理遊戲物件。例如:

  • 翹翹板的樞軸(see-saw pivot):水平部分連接到基座。使用關節的角度(Angle)限制來模擬翹翹板的最高點和最低點。
  • 剪刀(scissors)的樞軸:剪刀用鉸鏈連接在一起。使用關節的角度限制來模擬剪刀的閉合和最大打開。
  • 簡單的車輪連接到車體:車輪的樞軸點在車輪的中心與車體相連。你可以使用2D樞紐/鉸鏈關節(Hinge Joint 2D) 的馬達(motor)來旋轉車輪。
屬性 功能
Enable Collision 啟用這個屬性後,可以偵測碰撞。
Connected Rigidbody 指定此關節連接到的其他遊戲物件(GameObject)。如果將此設置為「None」,則關節的另一端固定在由「Connected Anchor」設置定義的空間點。
Auto Configure Connected Anchor 啟用此屬性以自動設置此2D鉸鏈關節(Hinge Joint 2D)連接到的遊戲物件的錨點(Anchor)位置。如果啟用此屬性,則不需要為「Connected Anchor」屬性輸入座標。
Anchor 定義此遊戲物件的2D剛體(Rigidbody2D)上的關節(joint)端點連接的位置(以x、y座標表示)。
Connected Anchor 定義這個關節(joint)端點要連接到另一個遊戲物件的2D剛體(Rigidbody2D)上的位置(以x、y座標表示)。
Use Motor 啟用此選項以對關節應用馬達動力。
Motor 選擇此選項以展開此屬性的設置。
Motor Speed 設定馬達要達到的目標速度(每秒度數)。
Maximum Motor Force 設定馬達在嘗試達到目標速度時可以施加的最大扭矩(torque)(或旋轉力(rotation))。
Use Limits 啟用此選項以限制旋轉的角度(rotation angle)。
Angle Limits 選擇此選項以展開對角度限制的設置。當Use Limits啟用時,就會應用這些限制。
Lower Angle 設定旋轉圓弧(rotation arc)下端(lower end)允許的極限。
Upper Angle 設定旋轉圓弧(rotation arc)上端(upper end)允許的極限。
Break Action 設置當超過力(force)臨界值或達到扭矩臨界值(torque threshold)時採取的動作。
Break Force 設置力的臨界值,當超過時這個關節就會執行選擇的Break Action。預設值為Infinity,表示不執行Break Action
Break Torque 設置扭矩的臨界值,當超過時這個關節就會執行選擇的Break Action。預設值為Infinity,表示不執行Break Action

Reference

Unity MonoBehavior

MonoBehaviour 是 Unity 中最重要的基類之一,用於控制遊戲物件(GameObject)的行為。並提供了一系列生命週期方法,這些方法在遊戲物件生命週期的不同階段會被調用。此外,還提供了一些輔助方法,用於執行常見任務,例如訪問遊戲物件和組件(Component)。


生命週期方法 (Lifecycle methods)

MonoBehaviour 提供了一系列生命週期方法,這些方法在遊戲物件生命週期的不同階段會被調用。這些方法包括

  • Awake:此方法在遊戲物件(GameObject)實體被建立時呼叫一次。
    • 通常用來:在APP開始前初始化遊戲物件狀態屬性,例如設置變量、添加組件(Component)等。
    • Unity會在所有啟用的遊戲物件都實體化之後,才呼叫Awake,所以可以呼叫FindWithTag,而不用擔心找不到啟用的遊戲物件。
    • Awake不可以使用協程(Awake cannot act as a coroutine)
    • 注意:Unity 不會保證用特定的順序調用每個遊戲物件的 Awake 方法。因此,不可以假設一個遊戲物件的 Awake 方法會在另一個遊戲物件的 Awake方法之前或之後調用。
      • 正確的方式是在 Awake 中設置自身的屬性和取得其他物件的引用,而在 Start 方法中處理需要依賴其他遊戲物件的屬性和引用。
    • 注意:如果如果遊戲物件是啟用(Enable)但是腳本(Script)並沒有被啟用,Awake依然會被執行。
      • 如果如果遊戲物件是啟用但是腳本並沒有被啟用,Awake依然會被執行
  • OnEnable:啟用時才會呼叫,在Awake之後執行,每次啟用都會呼叫
    • 通常在此註冊事件
  • Start:此方法在遊戲物件的所有組件都已初始化且第一幀渲染之前調用一次。
    • 當這個遊戲物件處於 Disabled 就不會呼叫,直到該物件第一次被啟用時,才呼叫。
    • 通常用來:處理開始遊戲物件開始的遊戲邏輯。
    • 是在AwakeOnEnable之後呼叫
    • Start可以使用協程 (Coroutine)
  • Update:此方法在遊戲的每一幀調用。
    • 當物件為啟用時才會呼叫Update
    • 通常用來:更新遊戲物件的狀態和處理使用者輸入。
  • FixedUpdate:此方法在固定的時間間隔調用,通常每 0.02 秒調用一次。
    • 通常用來:在此方法更新遊戲物件的物理模擬
    • 設定FixedUpdate的時間間隔
  • LateUpdate:此方法在所有的 Update 方法之後調用。
    • 它是更新依賴於 Update 方法結果的遊戲邏輯的理想場所。
    • 例如:攝影機跟隨(follow camera)就很適合放在此,因為它需要跟隨那些可能在 Update 中已經移動過的物件。
  • OnDisable:與OnEnable類似,但是是非啟用時才會呼叫,每次非啟用都會呼叫
    • 通常在此取消註冊事件
    • 當遊戲物件為disable時,不會呼叫UpdateFixedUpdateLateUpdate等相關更新方法。
    • 當遊戲物件為disable時,物件仍然在場景(Scene)中,該物件仍不會被釋放。
  • OnDestroy:此方法在遊戲物件被銷毀時調用一次。物件從場景(Scene)中被移除了,該物件所佔用的記憶體可以被釋放。
    • 當原先場景關閉,載入新的場景時,也會觸發OnDestroy方法
    • 通常用來:清理遊戲物件使用的資源。
    • 如果如果遊戲物件是啟用(Enable)但是腳本(Script)並沒有被啟用,移除該物件時,OnDestroy依然會被執行。
    • 注意:如果使用者是在手機平台上,那麼當使用者暫停你的APP時,作業系統(OS)可能會終止你的APP,而不會觸發 OnDestroy 方法,因此不要依賴此方法來保存APP的狀態。請將每次應用程序失去焦點視為APP退出,並使用 MonoBehaviour.OnApplicationFocus 來保存資料。

注意:
在Unity中預設是不會控制Script的執行順序,Unity的執行會先將所有腳本的Awake執行完之後,才去執行所有腳本的Start,如果真的需要依賴順序你需要到ProjectSetting中的Script Exceution Order去設定順序,數字越小的越先執行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class A : MonoBehaviour
{
// 最先呼叫
private void Awake()
{
Debug.Log($"{nameof(A)} is Awake");
}

// 啟用時才會呼叫,在Awake之後執行,每次啟用都會呼叫
private void OnEnable()
{
Debug.Log($"{nameof(A)} is OnEnable");
}

// 在第一幀開始前執行
void Start()
{
Debug.Log($"{nameof(A)} is Start");
}

// 每一幀執行
void Update()
{
Debug.Log($"{nameof(A)} is Update");
}

// 在 Update 之後執行,也是每一幀執行
private void LateUpdate()
{
Debug.Log($"{nameof(A)} is LateUpdate");
}

// 固定時間呼叫
private void FixedUpdate()
{
Debug.Log($"{nameof(A)} is FixedUpdate");
}

// 非啟用時才會呼叫,每次非啟用都會呼叫
private void OnDisable()
{
Debug.Log($"{nameof(A)} is OnDisable");
}

// 目前Component銷毀時才會呼叫
private void OnDestroy()
{
Debug.Log($"{nameof(A)} is OnDestroy");
}
}

Reference:

MVVM Pattern

MVVM模式(Model–View–ViewModel):MVVM將程式劃分為 ModelViewViewModel ,它透過觀察者模式將 ViewViewModel 連接在一起, 在 ViewModel 中進行資料綁定(data binding)。當 View Model 的狀態改變時自動通知 View

  • Model:主要負責管理資料邏輯。
  • View:負責顯示資料以及與使用者互動。
  • ViewModel:是連接 ModelView 的橋樑。與 MVC中的 Controller 和 MVP 中的 Presenter 不同在於 ViewModel 它會綁定(Binder) View 要顯示的資料,當資料改變時,自動通知 View 要更新UI。
    • 通常使用資料綁定(Data Binding)機制來實現這一點
graph TD
    A[ View ]
    B[ ViewModel ]
    C[ Model ]
    
    A -->| User Interaction | B
    B -->| Notify Changes | A
    B <-->| Data Binding | A
    B -->| Calls | C
    C -->| Data | B

Unity中,UGUI沒有內建Data Binding,需要自己實作一個,以下是一個簡單的範例

定義一個 Model

1
2
3
4
5
public class Model
{
public string Name { get; set; }
public string Job { get; set;}
}

定義一個Model Service,其職責為從PlayerPrefs拿資料,或是將資料寫入PlayerPrefs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ModelService
{

public Model GetData()
{
Model m = new Model()
{
Name = PlayerPrefs.GetString("name"),
Job = PlayerPrefs.GetString("job"),
};

return m;
}

public void SaveName(string name)
{
PlayerPrefs.SetString("name", name);
}

public void SaveJob(string job)
{
PlayerPrefs.SetString("job", job);
}
}

定義一個BindableProperty類,它含有一個OnValueChanged事件,當Value的值發生變化(通過set方法)時,就會呼叫 OnValueChanged ,從而通知有註冊這個事件的物件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class BindableProperty<T>
{
public delegate void ValueChangedHandler(T oldValue, T newValue);

public event ValueChangedHandler OnValueChanged;

private T _value;
public T Value
{
get
{
return _value;
}
set
{
if (!object.Equals(_value, value))
{
T oldValue = _value;
_value = value;
// 通知值發生改變
OnValueChanged?.Invoke(oldValue, _value);
}
}
}

public override string ToString()
{
return (Value != null ? Value.ToString() : "null");
}
}

接下來定義一個 ViewModel , 這個 ViewModel 將負責為 View 提供資料,但它不知曉 View 的任何資訊,與 View 解耦,專注於資料的處理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class ViewModel
{

public BindableProperty<string> Name = new BindableProperty<string>();
public BindableProperty<string> Job = new BindableProperty<string>();

private ModelService service = new ModelService();

public ViewModel()
{}

public void GetModel()
{
Model m = service.GetData();
Name.Value = m.Name;
Job.Value = m.Job;

Debug.Log(m.Name + " " + m.Job);
}

public void SaveModel()
{
// do some check
string name = Name.Value;
string job = Job.Value;

if (name == null || name.Length > 0)
{
return;
}
if (job == null || job.Length > 0)
{
return;
}

service.SaveName(Name.Value);
service.SaveJob(Job.Value);
}

}

最後定義一個 View ,這個 View 包含了要操控的UI元件,因此繼承了MonoBehaviour,它不處理資料邏輯,只負責顯示格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class View : MonoBehaviour
{
public TMP_InputField nameInputField;
public TextMeshProUGUI nameMessageText;

public TMP_InputField jobInputField;
public TextMeshProUGUI jobMessageText;

public TextMeshProUGUI resultText;
public Button applyButton;

public ViewModel viewModel;

private void Start()
{
viewModel = new ViewModel();
viewModel.Name.OnValueChanged += OnNameChanged;
nameInputField.onValueChanged.AddListener(OnNameInputChanged);

viewModel.Job.OnValueChanged += OnJobChanged;
jobInputField.onValueChanged.AddListener(OnJobInputChanged);

applyButton.onClick.AddListener(OnApplyButtonClick);

viewModel.GetModel();
}


private void OnDestroy()
{
viewModel.Name.OnValueChanged -= OnNameChanged;
nameInputField.onValueChanged.RemoveListener(OnNameInputChanged);

viewModel.Job.OnValueChanged -= OnJobChanged;
jobInputField.onValueChanged.RemoveListener(OnJobInputChanged);

applyButton.onClick.RemoveListener(OnApplyButtonClick);
}

private void OnNameChanged(string oldVal, string newVal)
{
nameMessageText.text = newVal;
}

private void OnNameInputChanged(string value)
{
viewModel.Name.Value = value;
}

private void OnJobChanged(string oldVal, string newVal)
{
jobMessageText.text = newVal;
}

private void OnJobInputChanged(string value)
{
viewModel.Job.Value = value;
}

private void OnApplyButtonClick()
{
resultText.text = viewModel.Name.Value + ", " + viewModel.Job.Value + " have been saved";
viewModel.SaveModel();
}
}

程式碼: https://github.com/mystudybook/Unity-MVVM

Reference: https://www.cnblogs.com/OceanEyes/p/unity3d_framework_designing_get_started_with_mvvm_part1.html

2D車輪關節(Wheel Joint 2D)

2D車輪關節(Wheel Joint 2D):是一種物理關節,可以用它來模擬轉動的車輪,

  • 可以為它施加馬達動力(motor power),
  • 它使用了懸吊彈簧(suspension spring)來保持與車輛主體(main body of the vehicle)的距離。

這種關節可用來模擬車輪(wheels)和懸掛系統(suspension)。其目的是保持兩個點在一條延伸到無限遠的直線上,同時使它們重疊(overlap)。這兩個點可以是兩個Rigidbody2D組件,也可以是一個Rigidbody2D組件和世界中的一個固定位置(通過將Connected Rigidbody設置為None來連接到世界中的固定位置)。

  • 簡單的說:2D車輪關節(Wheel Joint 2D),讓兩個點保持在同一條直線上,而這條直線是可以延伸到無限遠的。同時,當沒有外力作用時,這兩個點會保持在一起(即重疊)。

Wheel Joint 2D類似於Slider Joint 2D(無馬達動力或限制約束)和Hinge Joint 2D(無限制約束)的組合。
這種關節對兩個連接的剛體(rigid body)物件施加線性力(linear force),使它們保持在線上,並施加角度馬達(angular motor)以使物件在線上旋轉(rotate),同時用彈簧(spring)來模擬車輪懸掛(wheel suspension)。

設置
Maximum Motor Speed(最大馬達速度)與Maximum Motor Force(最大馬達動力)控制角馬達速度,讓兩個剛體物體旋轉。
Maximum Motor Force(最大馬達動力):設置角馬達的最大扭矩。

  • 即馬達所能施加的最大旋轉力。這個最大扭矩的設置對於控制車輪的轉動速度和力量非常重要。例如:
    • 如果 Maximum Motor Force 設置得太小,馬達可能無法提供足夠的扭矩來驅動車輪,特別是在遇到障礙或需要加速的情況下。
    • 如果 Maximum Motor Force 設置得太大,馬達可能會施加過大的扭矩,導致車輪快速旋轉,可能會影響車輛的穩定性
      模擬懸掛
      可以設置車輪懸掛的硬度和運動,以模擬不同程度的懸掛效果。例如,模擬硬且幾乎不動的懸掛:
  • 設置高頻率(Frequency)(1,000,000是最高值)== 硬懸掛。
  • 設置高阻尼比(Damping Ratio)(1是最高值)== 幾乎不動的懸掛。

若要模擬有彈性且自由運動的懸掛,可以使用以下設置:

  • 設置低頻率 == 鬆弛的懸掛。
  • 設置低阻尼比 == 可動的懸掛。

保持零相對線性距離(zero relative linear distance):在兩個剛體物體的錨點之間指定的線上保持零相對線性距離。
保持角速度(angular speed):在兩個剛體物體的錨點之間保持角速度(通過Maximum Motor Speed選項設置速度,通過Maximum Motor Force設置最大扭矩)。

應用場景

  • 這種關節適用於構建需要像是通過旋轉樞軸連接,但不能脫離指定線的物理對象。例如:模擬具有馬達驅動的車輪。
屬性 功能
Enable Collision 啟用這個屬性後,可以偵測碰撞。
Connected Rigid Body 指定此關節連接到的其他遊戲物件(GameObject)。如果將此設置為「None」,則關節的另一端固定在由「Connected Anchor」設置定義的空間點。
Auto Configure Connected Anchor 啟用此屬性以自動設置此關節連接到的遊戲物件的錨點(Anchor)位置。如果啟用此屬性,則不需要為「Connected Anchor」屬性輸入座標。
Anchor 定義此遊戲物件的2D剛體(Rigidbody2D)上的關節(joint)端點連接的位置(以x、y座標表示)。
Connected Anchor 定義這個關節(joint)端點要連接到另一個遊戲物件的2D剛體(Rigidbody2D)上的位置(以x、y座標表示)。
Suspension 選擇此選項以展開此屬性的設置。
Damping Ratio(阻尼比) 控制彈簧(spring)振盪(oscillation)的關節設置。較高的阻尼比意味著彈簧會更快停止振盪
Frequency 設定遊戲物件接近所需分離距離時彈簧振盪的頻率(以每秒週期數為單位)。範圍為0到1,000,000,值越高,彈簧越硬,也就是說越不容易來回振動。注意:將頻率設置為零會創建最硬的彈簧類型關節(即不振動)
Angle 設定懸掛系統(suspension)的世界(World)運動角度
Use Motor 啟用此選項以對關節應用馬達動力。
Motor 選擇此選項以展開此屬性的設置。
Motor Speed 設定馬達要達到的目標速度(每秒度數)。
Maximum Motor Force 設定馬達在嘗試達到目標速度時可以施加的最大扭矩(torque)(或旋轉力(rotation))。
Break Action 設置當超過力(force)臨界值或達到扭矩臨界值(torque threshold)時採取的動作。
Break Force 設置力的臨界值,當超過時這個關節就會執行選擇的Break Action。預設值為Infinity,表示不執行Break Action
Break Torque 設置扭矩的臨界值,當超過時這個關節就會執行選擇的Break Action。預設值為Infinity,表示不執行Break Action

Damping Ratio(阻尼比):描述系統在受到擾動後振盪及衰減的情形。阻尼比越大,物件受力振盪後越快恢復原狀。

  • 無阻尼:= 0 , 對應沒有阻尼的簡諧運動。
  • 欠阻尼:< 1 , 指數遞減且振盪
  • 過阻尼:> 1 , 沒有振盪的指數遞減
  • 臨界阻尼:= 1 , 介於過阻尼及欠阻尼之間,是許多工程應用想要的結果
  • https://zh.wikipedia.org/zh-tw/%E9%98%BB%E5%B0%BC%E6%AF%94

Reference:https://docs.unity3d.com/Manual/class-WheelJoint2D.html

閱讀Level Up Your Code With Game Programming Patterns

這本 Unity 的電子書《Level up your code with game programming patterns》 對於提升遊戲開發者的程式設計能力非常有幫助。
以下是該電子書的網址與Github
網址:https://blog.unity.com/games/level-up-your-code-with-game-programming-patterns
Github:https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main


這本書介紹了工程師必須了解的SOLID 原則(SOLID principles),這些原則是編寫高質量程式碼的基礎,SOLID原則是以下五大原則的簡稱:

  • 單一職責原則(Single responsibility):確保類只負責一件事。
  • 開閉原則(Open-closed):在不更改現有程式碼的情況下,可以擴展一個類的功能。
  • 里氏替換原則(Liskov substitution):子類可以替代基類,而不影響程式的正確性。
  • 介面隔離原則(Interface segregation):讓介面(interface)盡可能簡單,確保實作類只需要實作其所需的介面方法。
  • 依賴倒置原則(Dependency inversion):高層模組不應依賴於低層模組,兩者都應依賴於抽象。具體實現應依賴於抽象,而不是抽象依賴於具體實現。

這些原則可以讓你的程式設計更具彈性和可維護性,但在不確定是否要使用它們時,請記住KISS(Keep It Simple, Stupid)原則,不要強迫將其應用於程式碼中。


這本電子書也介紹了以下常用在遊戲開發中的設計模式

MVP Pattern

MVC模式(Model-View-Controller):MVC模式在開發UI中是一種非常受歡迎的設計模式。

  • MVC主要想法為:將程式劃分為模型(Model)、視圖(View)和控制器(Controller
    • Model:主要負責管理資料邏輯。Model拿到資料後將資料交給View
      • 在此可以直接與資料庫、API 或其他資料來源交互。
      • 在此不執行遊戲邏輯或是執行運算。
    • View:負責顯示資料。
      • 它從Model中獲取資料,並將這些資料呈現給使用者。View僅關注如何顯示資料。
    • Controller:充當ModelView之間的溝通的橋樑。
      • 它接收使用者的輸入,呼叫Model來處理這些輸入,然後選擇適當的View來顯示處理結果。
      • 可以把它想像為大腦,它處理遊戲資料並在執行期間計算資料是如何改變的。
  • MVC模式符合單一職責原則,每個部分都只負責自己的部分。
  • 在Unity中,UI Toolkit或是Unity UI很自然地負責擔任View
  • 缺點
    • MVC模式中三者相互依賴,一但更新了其中一個,另外兩個也須跟著修改。
    • Controller的程式碼會隨著功能的添加越來越臃腫。
graph TD
    A[ User ] -->| Interacts with | B[ View ]
    B -->| Sends user input to | C[ Controller ]
    C -->| Updates | D[ Model ]
    D -->| Notifies changes to | B[ View ]
    B -->| Displays data from | D[ Model ]

MVP模式(Model-View-Presenter):MVP是MVC的一種變體

  • MVP將Controller改爲Presenter,並改變了通信方向。當Model拿到資料之後,不直接給View更新,而是交給Presenter,之後Presenter再把資料交給ViewView再更新畫面。
    • View:只負責收到使用者回饋,之後呼叫Presenter拿取資料,並在接收到資料時,更新畫面。
    • Model:被動的接收到Presenter命令,拿取資料,並回傳給Presenter
    • PresenterModelView之間的的橋樑,與ViewModel溝通。
  • 從三者相互依賴變成都只依賴Presenter
    • M <=> P <=> V 之間雙向通信但ViewModel不通信
  • Presenter的程式碼會隨著功能的添加越來越臃腫。
graph TD
    A[ User ] -->| Interacts with  | B[ View ]
    B -->| Sends user input to | C[ Presenter ]
    C -->| Updates | D[ Model ]
    D -->| Notifies changes to | C[ Presenter ]
    C -->| Updates | B[ View ]
    B -->| Displays data from | C[ Presenter ]

使用MVP模式製作生命條UI:
Health類

  • 在此Health類的身份是Model,保存真正的生命資料
  • 含有一個HealthChanged event,每當有更改生命值的動作時,都會呼叫這個event。
  • Health類只負責增加,減少生命值,符合符合單一職責原則
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    public class Health : MonoBehaviour
    {
    public event Action HealthChanged;

    private const int minHealth = 0;
    private const int maxHealth = 100;
    private int currentHealth;

    public int CurrentHealth
    {
    get => currentHealth;
    set => currentHealth = value;
    }

    public int MinHealth => minHealth;
    public int MaxHealth => maxHealth;

    public void Increment(int amount)
    {
    currentHealth += amount;
    currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
    UpdateHealth();
    }

    public void Decrement(int amount)
    {
    currentHealth -= amount;
    currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
    UpdateHealth();
    }

    public void Restore()
    {
    currentHealth = maxHealth;
    UpdateHealth();
    }

    private void UpdateHealth()
    {
    HealthChanged?.Invoke();
    }
    }

HealthPresenter類

  • HealthPresenter類含有Health類的依賴,用來操控Health
  • 其他物件不會直接操控Health類,而是透過HealthPresenter類暴露的DamageHeal以及Reset來操控
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    public class HealthPresenter : MonoBehaviour
    {
    // 在MVP中的Model
    [SerializeField] private Health health;

    // 在MVP中的View
    [SerializeField] private Slider healthSlider;

    private void Start()
    {
    if (health != null)
    {
    health.HealthChanged += OnHealthChanged;
    }
    UpdateView();
    }

    private void OnDestroy()
    {
    if (health != null)
    {
    health.HealthChanged -= OnHealthChanged;
    }
    }

    public void Damage(int amount)
    {
    health?.Decrement(amount);
    }

    public void Heal(int amount)
    {
    health?.Increment(amount);
    }

    public void Reset()
    {
    health?.Restore();
    }

    public void UpdateView()
    {
    if (health == null) return;

    if (healthSlider != null && health.MaxHealth != 0)
    {
    healthSlider.value = (float)health.CurrentHealth / health.MaxHealth;
    }
    }

    private void OnHealthChanged()
    {
    UpdateView();
    }
    }

ClickDamage類:為使用HealthPresenter的類

  • 不直接操控 Model (Health)與 View (healthSlider)而是透過HealthPresenter
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    [RequireComponent(typeof(HealthPresenter), typeof(Collider))]
    public class ClickDamage : MonoBehaviour
    {
    private Collider collider;
    private HealthPresenter healthPresenter;
    [SerializeField] private LayerMask layerToClick;
    [SerializeField] private int damageValue = 10;

    private void Start()
    {
    collider = GetComponent<Collider>();
    healthPresenter = GetComponent<HealthPresenter>();
    }

    private void Update()
    {
    if (Input.GetMouseButtonDown(0))
    {
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    if (Physics.Raycast(ray, Mathf.Infinity, layerToClick))
    {
    healthPresenter?.Damage(damageValue);
    }
    }
    }
    }

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/12%20MVP
https://github.com/push-pop/Unity-MVVM/blob/main/Docs/Architechture.md

State Pattern

狀態模式(State pattern):

  • 當物件的內部狀態改變時,物件的行為也會隨之改變,即物件會根據當前狀態執行不同的行為。
  • 每個狀態的行為是獨立定義的。添加新狀態不會影響現有狀態的行為。

在遊戲中會需要不斷地追蹤遊戲中的各種狀態,例如玩家走路(Walking),跳躍(Jumping),待命(IdleStanding)等。若你把它畫成流程圖(flowchart)可能會發現

  • 一次只有一個狀態會被啟用(Active)
  • 狀態會依據不同的條件去轉換到另外一個狀態
  • 當轉換發生時,新的狀態會成為啟用狀態(Active state)
  • 這種狀態轉換圖被稱為有限狀態機(Finite state machineFSM)

以下是一個使用Enum去實作的簡單有限狀態機:

1
2
3
4
5
6
public enum PlayerControllerState
{
Idle,
Walk,
Jump
}

在這個Controller類中,使用switch轉換狀態,很快你會發現當狀態越來越多後,Controller會變得一團亂,例如增加幾個狀態,就必須要不斷的更改Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class UnrefactoredPlayerController : MonoBehaviour
{
private PlayerControllerState state;
private void Update()
{
GetInput();
switch (state)
{
case PlayerControllerState.Idle:
Idle();
break;
case PlayerControllerState.Walk:
Walk();
break;
case PlayerControllerState.Jump:
Jump();
break;
}
}
private void GetInput()
{
// 處理行走和跳躍控制
}
private void Walk()
{
// 行走邏輯
}
private void Idle()
{
// 待命邏輯
}
private void Jump()
{
// 跳躍邏輯
}
}

使用狀態模式,在遊戲中,你可以想像處理角色狀態需要:

  1. 當條件滿足時,第一次進入(Enter)這個狀態時需要處理一些事。
  2. 在遊戲迴圈中根據這個狀態不斷的更新(Update)角色的值。
  3. 當條件不滿足或是滿足某些離開條件時,離開(Exit)這個狀態時,要在離開前處理一些事。
    因此根據這樣的流程定義一個抽象介面(Interface),IState並定義它有以下方法:
  • Enter:第一次進入這個狀態時需要做的邏輯
  • Update: 在每一幀中需要執行的邏輯(有時也稱ExceuteTick)。它會根據這個狀態在每一幀中不斷的更新角色的值。
  • Exit:在離開這個狀態前要做的邏輯
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public interface IState
    {
    public void Enter()
    {
    // 當第一次進入狀態時運行的邏輯
    }
    public void Update()
    {
    // 每幀邏輯,包括轉換到新狀態的條件
    }
    public void Exit()
    {
    // 當離開狀態時運行的邏輯
    }
    }

行走狀態:在這個狀態中,Update()會不斷地移動這個角色,另外也會判斷是否要切換到其他狀態。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class WalkState : IState
{
private Color meshColor = Color.blue;
public Color MeshColor { get => meshColor; set => meshColor = value; }

private PlayerController player;

// 可以在建構子中傳遞任何需要的參數
public WalkState(PlayerController player)
{
this.player = player;
}

public void Enter()
{
//Debug.Log("Entering Walk State");
}

public void Update()
{
// 如果不再在地面上,轉換到跳躍狀態
if (!player.IsGrounded)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.jumpState);
}

// 如果速度降到最低臨界值以內,轉換到待命狀態
if (Mathf.Abs(player.CharController.velocity.x) < 0.1f && Mathf.Abs(player.CharController.velocity.z) < 0.1f)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.idleState);
}
}

public void Exit()
{
//Debug.Log("Exiting Walk State");
}

}

待命狀態

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class IdleState : IState
{
private PlayerController player;

private Color meshColor = Color.gray;
public Color MeshColor { get => meshColor; set => meshColor = value; }

public IdleState(PlayerController player)
{
this.player = player;
}

public void Enter()
{
Debug.Log("Entering Idle State");
}

public void Update()
{
// 如果不再在地面上,轉換到跳躍狀態
if (!player.IsGrounded)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.jumpState);
}

// 如果速度超過臨界值,轉換到移動狀態
if (Mathf.Abs(player.CharController.velocity.x) > 0.1f || Mathf.Abs(player.CharController.velocity.z) > 0.1f)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.walkState);
}
}

public void Exit()
{
Debug.Log("Exiting Idle State");
}
}

StateMachine負責管理這些狀態

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class StateMachine
{
public IState CurrentState { get; private set; } // 目前狀態

// 狀態物件的引用
public WalkState walkState;
public JumpState jumpState;
public IdleState idleState;

// 用於通知其他物件狀態改變的事件
public event Action<IState> stateChanged;

// 由於沒有繼承 MonoBehaviour,因此需要通過建構子將 PlayerController 傳入
public StateMachine(PlayerController player)
{
// 建立每個狀態的實體並傳遞 PlayerController
this.walkState = new WalkState(player);
this.jumpState = new JumpState(player);
this.idleState = new IdleState(player);
}

// 設置初始狀態
public void Initialize(IState state)
{
CurrentState = state;
state.Enter();

// 通知其他物件狀態已改變
stateChanged?.Invoke(state);
}

// 退出目前狀態並進入另一個狀態
public void TransitionTo(IState nextState)
{
CurrentState.Exit();
CurrentState = nextState;
nextState.Enter();

// 通知其他物件狀態已改變
stateChanged?.Invoke(nextState);
}

public void Update()
{
if (CurrentState != null)
{
CurrentState.Update();
}
}
}

在Controller中使用StateMachine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
[RequireComponent(typeof(PlayerInput), typeof(CharacterController))]
public class PlayerController : MonoBehaviour
{
[SerializeField] private PlayerInput playerInput;
private StateMachine playerStateMachine;

[Header("Movement")]
[Tooltip("Horizontal speed")]
[SerializeField] private float moveSpeed = 5f;
[Tooltip("Rate of change for move speed")]
[SerializeField] private float acceleration = 10f;
[Tooltip("Max height to jump")]
[SerializeField] private float jumpHeight = 1.25f;

[Tooltip("Custom gravity for player")]
[SerializeField] private float gravity = -15f;
[Tooltip("Time between jumps")]
[SerializeField] private float jumpTimeout = 0.1f;

[SerializeField] private bool isGrounded = true; // 是否在地面上
[SerializeField] private float groundedRadius = 0.5f; // 檢查是否在地面的半徑
[SerializeField] private float groundedOffset = 0.15f;
[SerializeField] private LayerMask groundLayers;

public CharacterController CharController => charController;
public bool IsGrounded => isGrounded;
public StateMachine PlayerStateMachine => playerStateMachine;

private CharacterController charController;
private float targetSpeed;
private float verticalVelocity;
private float jumpCooldown;

private void Awake()
{
playerInput = GetComponent<PlayerInput>();
charController = GetComponent<CharacterController>();

// 初始化狀態機
playerStateMachine = new StateMachine(this);
}

private void Start()
{
playerStateMachine.Initialize(playerStateMachine.idleState); // 設置初始狀態為 idleState
}

private void Update()
{
// 使用目前狀態更新角色資料
playerStateMachine.Update();
}

private void LateUpdate()
{
CalculateVertical();
Move();
}

private void Move()
{
Vector3 inputVector = playerInput.InputVector;

if (inputVector == Vector3.zero)
{
targetSpeed = 0;
}

float currentHorizontalSpeed = new Vector3(charController.velocity.x, 0.0f, charController.velocity.z).magnitude;
float tolerance = 0.1f;

if (currentHorizontalSpeed < targetSpeed - tolerance || currentHorizontalSpeed > targetSpeed + tolerance)
{
targetSpeed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed, Time.deltaTime * acceleration);
targetSpeed = Mathf.Round(targetSpeed * 1000f) / 1000f;
}
else
{
targetSpeed = moveSpeed;
}

charController.Move((inputVector.normalized * targetSpeed * Time.deltaTime) + new Vector3(0f, verticalVelocity, 0f) * Time.deltaTime);
}

private void CalculateVertical()
{
if (isGrounded)
{
if (verticalVelocity < 0f)
{
verticalVelocity = -2f;
}

if (playerInput.IsJumping && jumpCooldown <= 0f)
{
verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
}

if (jumpCooldown >= 0f)
{
jumpCooldown -= Time.deltaTime;
}
}
else
{
jumpCooldown = jumpTimeout;
playerInput.IsJumping = false;
}

verticalVelocity += gravity * Time.deltaTime;

Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y + groundedOffset, transform.position.z);
isGrounded = Physics.CheckSphere(spherePosition, 0.5f, groundLayers, QueryTriggerInteraction.Ignore);
}

private void OnDrawGizmosSelected()
{
Color transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
Color transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);

Gizmos.color = isGrounded ? transparentGreen : transparentRed;

Gizmos.DrawSphere(new Vector3(transform.position.x, transform.position.y + groundedOffset, transform.position.z), groundedRadius);
}
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/10%20State/Scripts

依賴(dependency)

依賴(Dependency):通常指的是一個類別依賴於另一個類別或介面來完成其功能。

  • 依賴使得類別之間的耦合度增加,讓系統變得難以維護和測試。

依賴關係的方向性可以分為單向依賴(Unidirectional Dependency)和雙向依賴(Bidirectional Dependency)。

  • 單向依賴(Unidirectional Dependency):是指一個類別或模組依賴於另一個類別或模組,但相反的依賴關係不存在。這種依賴關係是單向的,即依賴的方向是單一的,這種依賴的程式碼較容易維護和擴展。

    • 在下面例子中,Car 類別依賴於 Engine 類別,但 Engine 類別並不依賴 Car 類別。這就是一個單向依賴的例子。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      public class Engine
      {
      public void Start()
      {
      Console.WriteLine("Engine started.");
      }
      }

      public class Car
      {
      private Engine _engine;

      public Car(Engine engine)
      {
      _engine = engine;
      }

      public void Start()
      {
      _engine.Start();
      }
      }
  • 雙向依賴(Bidirectional Dependency):是指兩個類別或模組互相依賴,形成了雙向的依賴關係。這種依賴關係會增加系統的耦合度,使得程式碼難以維護和測試。

    • 通常在設計中應該盡量避免雙向依賴,或者使用依賴注入和接口等技術來減少耦合度。
    • 在這個例子中,Engine 類別依賴於 Car 類別,並且 Car 類別也依賴於 Engine 類別,形成了雙向依賴。這種設計會使得類別之間的關係變得複雜,難以維護和測試。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      public class Engine
      {
      private Car _car;

      public Engine(Car car)
      {
      _car = car;
      }

      public void Start()
      {
      Console.WriteLine("Engine started.");
      _car.NotifyEngineStarted();
      }
      }

      public class Car
      {
      private Engine _engine;

      public Car()
      {
      _engine = new Engine(this);
      }

      public void Start()
      {
      _engine.Start();
      }

      public void NotifyEngineStarted()
      {
      Console.WriteLine("Car notified: Engine started.");
      }
      }

      public class Program
      {
      public static void Main(string[] args)
      {
      Car car = new Car();
      car.Start();
      }
      }

Command Pattern

命令模式(Command Pattern):當你想要追蹤一系列特定動作時,可以使用命令模式(command pattern)。如果你玩過帶有撤銷(undo)/重做(redo)功能或是能夠在列表中保留輸入歷史的遊戲,你很可能已經見過命令模式。想像一個策略遊戲,玩家可以在實際執行之前計劃多個動作,這就是命令模式。

在設計模式中,命令模式有以下角色參與其中

  • Command:抽象命令,可能為一個介面(Interface)或是抽象類(Abstract Class),例如ICommand
  • ConcreteCommand:具體命令,即實作抽象命令的Class
  • Invoker:命令的調用者,發起者,管理者
  • Receiver:接收者,實際執行命令者,被Command訪問與操作
  • 使用命令的客戶端(Client)

命令模式特點:

  • 命令模式不直接呼叫方法(method)而是會將單個或多個方法封裝為命令物件(command object)。
  • 命令物件會存放到一個容器(像是Stack或是Queue)中,讓你可以在想要的時間點去執行或是撤銷這些命令物件。
  • 新增命令不會影響其他地方,符合了開閉原則(Open-closed principle)
  • 除了撤銷(undo)/重做(redo)功能,也可以用來實現回放(Play back)功能

接收者(Receiver)類:PlayerMover,在命令物件執行時真正執行動作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class PlayerMover : MonoBehaviour
{
[SerializeField] private LayerMask obstacleLayer;

private const float boardSpacing = 1f;
private PlayerPath playerPath;
public PlayerPath PlayerPath => playerPath;

private void Start()
{
playerPath = gameObject.GetComponent<PlayerPath>();
}

public void Move(Vector3 movement)
{
Vector3 destination = transform.position + movement;
transform.position = destination;
}

public bool IsValidMove(Vector3 movement)
{
return !Physics.Raycast(transform.position, movement, boardSpacing, obstacleLayer);
}
}

定義一個抽象命令介面(ICommand)

1
2
3
4
5
public interface ICommand
{
void Execute();
void Undo();
}

定義一個具體命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MoveCommand : ICommand
{
private PlayerMover _playerMover;
private Vector3 _movement;

public MoveCommand(PlayerMover player, Vector3 moveVector)
{
this._playerMover = player;
this._movement = moveVector;
}

public void Execute()
{
_playerMover?.PlayerPath.AddToPath(_playerMover.transform.position + _movement);
_playerMover.Move(_movement);
}

public void Undo()
{
_playerMover.Move(-_movement);
_playerMover?.PlayerPath.RemoveFromPath();
}
}

定義一個使用命令的命令调用者類CommandInvoker,它只負責執行(ExecuteCommand())和撤銷(UndoCommand())命令。

  • 它含有一個容器(undoStack),用來儲存一系列的命令。
  • 當執行命令時,從容器中取出一個命令物件執行方法(Execute)
  • 當撤消命令時,從容器中取出一個命令物件,並執行撤銷方法(Undo)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public class CommandInvoker
    {
    private static Stack<ICommand> _undoStack = new Stack<ICommand>();
    private static Stack<ICommand> _redoStack = new Stack<ICommand>();

    public static void ExecuteCommand(ICommand command)
    {
    command.Execute();
    _undoStack.Push(command);
    _redoStack.Clear();
    }

    public static void UndoCommand()
    {
    if (_undoStack.Count > 0)
    {
    ICommand activeCommand = _undoStack.Pop();
    _redoStack.Push(activeCommand);
    activeCommand.Undo();
    }
    }

    public static void RedoCommand()
    {
    if (_redoStack.Count > 0)
    {
    ICommand activeCommand = _redoStack.Pop();
    _undoStack.Push(activeCommand);
    activeCommand.Execute();
    }
    }
    }

使用命令的客戶端(Client)類,InputManager,它透過CommandInvoker執行命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class InputManager : MonoBehaviour
{
[Header("Button Controls")]
[SerializeField] Button forwardButton;
[SerializeField] Button backButton;
[SerializeField] Button leftButton;
[SerializeField] Button rightButton;
[SerializeField] Button undoButton;
[SerializeField] Button redoButton;

[SerializeField] private PlayerMover player;

private void Start()
{
forwardButton.onClick.AddListener(OnForwardInput);
backButton.onClick.AddListener(OnBackInput);
rightButton.onClick.AddListener(OnRightInput);
leftButton.onClick.AddListener(OnLeftInput);
undoButton.onClick.AddListener(OnUndoInput);
redoButton.onClick.AddListener(OnRedoInput);
}

private void RunPlayerCommand(PlayerMover playerMover, Vector3 movement)
{
if (playerMover == null) return;
if (playerMover.IsValidMove(movement))
{
ICommand command = new MoveCommand(playerMover, movement);
CommandInvoker.ExecuteCommand(command);
}
}

private void OnLeftInput() { RunPlayerCommand(player, Vector3.left); }
private void OnRightInput() { RunPlayerCommand(player, Vector3.right); }
private void OnForwardInput() { RunPlayerCommand(player, Vector3.forward); }
private void OnBackInput() { RunPlayerCommand(player, Vector3.back); }
private void OnUndoInput() { CommandInvoker.UndoCommand(); }
private void OnRedoInput() { CommandInvoker.RedoCommand(); }
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/9%20Command