[ 오브젝트 물리 적용 ]
중력
빗물을 표현하기 위한 원 오브젝트를 생성한다. 하지만 프로젝트를 실행하면 원은 그 자리에 가만히 있고 움직이지 않는다. 따라서 오브젝트에 물리 작용을 줘야 하는데, 그중 아래로 떨어뜨리기 위해서 중력을 사용해야 한다.
Rain 오브젝트에서 Add Component를 클릭하고 Rigidbody 2D를 클릭한다. Rigidbody 2D는 2차원 프로젝트에서 오브젝트에 중력을 부여하는 요소이다. 프로젝트를 실행하면 빗물이 아래로 떨어지는 것을 확인할 수 있다.
하지만 빗물이 바닥을 뚫고 계속해서 아래로 떨어지는 문제가 생긴다. 이를 해결하기 위해서 빗물과 바닥이 부딪혔을 때, 빗물이 멈추고 파괴되도록 해야한다.
충돌
Rain 오브젝트에 충돌을 적용시키기 위해 Add Component에서 Circle Collider 2D를 추가한다.
Rain 오브젝트와 Ground 오브젝트의 물리적 경계를 시각화하면 다음과 같다. Collider 2D를 추가하기 전에는 두 오브젝트 모두 통과되지만, Rain 오브젝트에 물리적 경계가 생성됨으로써 충돌할 수 있게 됐다. 하지만 Ground는 여전히 물리적 경계가 생성되지 않았으므로, Rain 오브젝트는 그대로 통과하는 모습을 보인다.
이를 해결하기 위해 Ground 오브젝트에도 Collider 2D를 추가한다. 아까와는 스프라이트가 다르므로 Box로 추가한다.
Ground 오브젝트까지 Collider를 추가하고 나면, 두 오브젝트 모두 물리적 경계가 생성되어 충돌을 감지할 수 있다. 프로젝트를 실행하면 Rain 오브젝트가 떨어지다가 Ground 오브젝트와 부딪히면 그 자리에서 멈추게 된다.
[ 오브젝트 파괴 ]
땅에 닿은 Rain 오브젝트가 쌓이지 않도록 파괴하는 코드를 작성하기 위해 Rain 스크립트를 생성한다.
public class Rain : MonoBehaviour
{
void Start()
{
}
void Update()
{
}
private void OnCollisionEnter2D(Collision2D collision)
{
Debug.Log("충돌");
if (collision.gameObject.name == "Ground")
{
Destroy(this.gameObject);
}
}
}
public이 어디서든 접근이 가능했다면, private은 해당 클래스 내에서만 접근이 가능하다는 것을 의미한다. OnCollisionEnter2D() 함수는 2D 오브젝트가 다른 오브젝트와 충돌했을 때 자동으로 호출된다. 소괄호 안의 Collision2D collision은 충돌 이벤트를 포함하고 있는 Collision2D 타입의 변수 collision을 선언한 것이다. 오브젝트에 충돌 이벤트가 발생했을 때, 충돌과 관련된 데이터가 collision 변수에 저장된다.
Debug.Log()를 통해 충돌이 발생했을 때, 콘솔에 충돌이라는 메시지를 출력한다. 화면상에는 나타나지 않지만, 제대로 충돌이 발생했는지 확인하기 위해 작성하는 코드이기 때문에 반드시 들어가지 않아도 된다.
if (collision.gameObject.name == "Ground")는 충돌 이벤트가 발생했을 때 작동하는 조건문이다. collision에는 Rain 오브젝트와 충돌했을 때 발생한 충돌과 관련된 데이터가 저장되어 있다. 그리고 collision 변수에서 gameObject의 이름이 Ground라면 코드를 실행한다는 의미이다. 여기서 gameObject는 Rain 오브젝트와 충돌한 오브젝트이다.
조건을 만족했을 때 Destroy(this.gameObject) 코드를 실행한다. Destroy() 함수는 소괄호 안의 인자로 받은 오브젝트를 씬에서 제거한다. this.gameObject는 해당 스크립트가 있는 오브젝트인 Rain을 의미한다. 본인을 의미하는 경우에는 this를 생략하고 gameObject만 작성해도 정상 작동된다. 만약 Ground를 제거하고 싶다면, collision 변수에 Ground 데이터가 담겨있으므로 collision.gameObject로 작성한다.
만약 오브젝트명이 바뀌게 된다면, 코드가 정상 작동되지 않기 때문에 오브젝트에 태그를 달고 이 태그를 인식하게 하는 방법도 있다. 태그를 달고 싶은 오브젝트를 선택하고, Add Tag에서 태그를 생성한 후 해당 태그를 선택하면 된다.
private void OnCollisionEnter2D(Collision2D collision)
{
Debug.Log("충돌");
if (collision.gameObject.tag == "Ground")
{
Destroy(this.gameObject);
}
}
코드도 name에서 tag로 수정하면, 오브젝트명이 바뀌더라도 태그만 유지된다면 코드가 정상 작동된다.
Debug.Log("충돌");
if (collision.gameObject.CompareTag("Ground"))
{
Destroy(collision.gameObject);
}
또는 CompareTag() 함수를 사용할 수도 있다. gameObject의 태그가 Ground와 일치하는지 비교하는 기능을 한다.
Debug.Log("충돌");
if (collision.gameObject.CompareTag("Hi")) //존재하지 않는 태그 입력
{
Destroy(collision.gameObject);
}
둘 다 태그를 확인하는 기능으로 동일하지만, CompareTag()를 사용하는 것이 더 추천된다.
CompareTag()는 == 연산자를 사용하는 것보다 효율적으로 작동해서 성능 최적화가 가능하고, 존재하지 않는 태그를 입력하면 콘솔창에 경고 메시지를 띄워 사용자가 에러를 알 수 있도록 한다.
[ 오브젝트 랜덤 속성 ]
위치
void Start()
{
float x = Random.Range(-2.4f, 2.4f);
float y = Random.Range(3.0f, 5.0f);
transform.position = new Vector3(x, y, 0);
}
Rain 오브젝트가 생성될 위치를 먼저 확인한다. X축은 -2.4부터 2.4까지, Y축은 3부터 5까지 범위를 지정했다. X축 위치를 소수점 단위로 지정할 것이기 때문에 float 타입의 변수 x를 생성한다. Random은 유니티 내부 클래스로 랜덤한 값을 생성한다. Random 클래스의 Range() 함수를 이용해 범위를 지정할 수 있다. 두 개의 인자 값은 각각 최솟값(이상), 최댓값(미만)을 의미한다. 이를 식으로 나타내면 -2.4≤x<2.4로 표현할 수 있다. Y축에 대한 코드도 작성한 뒤, 두 변수들을 실제 위치에 적용시켜 주기 위해 transform.position = new Vector3(x, y, 0)을 작성해 준다.
위 코드를 실행하면, 프로젝트를 실행할 때마다 위치가 랜덤하게 생성된 것을 확인할 수 있다.
이외에도 크기와 색상을 랜덤으로 생성하고, 크기에 따라 점수가 달라지도록 하는 코드를 추가할 수 있다.
크기
public class Rain : MonoBehaviour
{
float size;
void Start()
{
int type = Random.Range(1, 4);
if (type == 1)
{
size = 0.8f;
}
else if (type == 2)
{
size = 1.0f;
}
else
{
size = 1.2f;
}
transform.localScale = new Vector3(size, size, 0);
}
크기에 대한 데이터를 저장할 수 있는 float 타입 변수 size를 생성한다. 위 코드에서는 Start 메서드 밖에 보이지 않지만, Update 메서드에서도 사용할 수 있도록 클래스 최상단에 작성해서 모든 메소드에서 접근할 수 있는 전역 변수로 지정해 준다.
다음으로 크기의 종류를 나타내는 type 변수를 생성한다. 위치와는 다르게 정수로 표현해도 되므로 int 타입을 사용한다. 총 세 가지 타입을 만들 건데, 범위를 (1, 3)으로 지정하면 1≤type<3이기 때문에 type이 가질 수 있는 수는 1, 2가 되므로 최댓값을 4로 설정한다.
조건문을 사용해서 type이 1일 때와 2일 때, 3일 때 각각의 코드가 실행되도록 한다. 조건마다 if문을 새롭게 작성할 수도 있지만, 이전에서 조건을 만족해 실행이 되었을 경우에도 다음 코드를 자동으로 실행하기 때문에 성능 최적화를 위해 else if와 else를 사용한다. 각 조건을 만족했을 때 size가 0.8, 1.0, 1.2가 되도록 코드를 작성한다.
그리고 size에 저장된 데이터를 실제로 오브젝트에 적용시키기 위해 transform.localScale = new Vector3(size, size, 0) 코드를 통해서 오브젝트의 scale 값을 size 변수 값으로 변화시킨다. X와 Y 모두 size 변수가 들어가므로, 가로와 세로 길이가 같은 원이 만들어진다.
❓ 왜 크기는 scale이 아닌 localScale을 사용할까?
transform의 position은 transform.position을 사용한다. 하지만 scale은 transform.localScale을 사용한다.
이 차이는 바로 각 속성이 참조하는 시스템인 좌표계가 달라서 발생한다.
캐릭터가 게임 맵 상에서 어딘가에 있다면, 그 위치는 x좌표와 y좌표로 고정 값을 정의할 수 있다. 이를 월드 좌표계라고 한다. 따라서 오브젝트의 위치는 전역적인 참조 시스템에 기반하여 결정된다.
캐릭터가 몬스터와 싸우기 위해서 들고 있는 다양한 무기들이 있다. 이 무기들도 하나의 오브젝트로서 캐릭터의 크기뿐만 아니라 무기 또한 크기를 조정할 수 있다. 만약 무기는 그대로 사용하고 캐릭터만 바꾸는 경우에는 어떻게 될까?
무기의 크기가 (2, 2, 2)로 고정된 값이 된다면, 캐릭터는 커졌지만 무기의 크기는 그대로일 것이다. 하지만 우리는 캐릭터가 커지면 무기의 크기도 커지고, 캐릭터가 작아지면 무기의 크기도 작아지길 원한다. 여기서 캐릭터를 부모 오브젝트, 무기를 자식 오브젝트라고 부르는데, 이처럼 크기는 계층적 구조를 가지고 있기 때문에 자식 오브젝트는 부모 오브젝트의 크기에 영향을 받는다. 이를 로컬 좌표계라고 하며, 부모 오브젝트에 대한 상대적인 참조 시스템에 기반한다.
따라서 크기는 로컬 좌표계를 참조하기 때문에 scale 속성을 가져올 때는 transform.scale이 아닌, transform.localScale을 사용한다.
자식 오브젝트의 최종 스케일을 계산하는 공식은 다음과 같다.
[ 최종 스케일=부모 스케일×자식의 localScale ]
+ 24.05.08.
local은 로컬 좌표계를 참조하려는 객체는 모두 사용할 수 있다. 따라서 localPosition, localRotation도 가능하다. 하지만 월드 좌표계에서는 position, rotation으로 표현이 가능한 반면, scale은 독단적으로 사용할 수 없고 앞에 'lossy'라는 단어를 붙여야 한다. 따라서 위 내용은 scale에만 local이 붙는다는 의미가 아닌, transform.scale이라는 표현이 존재하지 않는다는 것에 집중한다.
계층 구조와 lossy에 대한 자세한 내용은 아래 글의 '계층 구조' 목차에서 확인할 수 있다.
2024.05.08 - [Coding/Unity] - [내일배움캠프 17일차 TIL] 게임 엔진, PPU, 계층 구조, Input.GetAxis, 직렬화
점수
public class Rain : MonoBehaviour
{
float size;
int score;
void Start()
{
int type = Random.Range(1, 4);
if (type == 1)
{
size = 0.8f;
score = 1;
}
else if (type == 2)
{
size = 1.0f;
score = 2;
}
else
{
size = 1.2f;
score = 3;
}
transform.localScale = new Vector3(size, size, 0);
}
크기가 클수록 점수도 높아지도록 코드를 작성한다. 정수의 점수를 부여하기 위해 int 타입의 전역 변수로 score를 생성한다. 그리고 조건문에 크기가 가장 작으면 1, 중간 크기면 2, 가장 크면 3을 저장하게 한다.
색상
public class Rain : MonoBehaviour
{
float size;
int score;
SpriteRenderer renderer;
void Start()
{
renderer = GetComponent<SpriteRenderer>();
int type = Random.Range(1, 4);
if (type == 1)
{
size = 0.8f;
score = 1;
renderer.color = new Color(100 / 255f, 100 / 255f, 1f, 1f);
}
else if (type == 2)
{
size = 1.0f;
score = 2;
renderer.color = new Color(130 / 255f, 130 / 255f, 1f, 1f);
}
else
{
size = 1.2f;
score = 3;
renderer.color = new Color(150 / 255f, 150 / 255f, 1f, 1f);
}
transform.localScale = new Vector3(size, size, 0);
}
색상은 SpriteRenderer 컴포넌트에서 변경할 수 있으므로, renderer 변수를 생성하고 GetComponent() 함수로 컴포넌트 접근 권한을 부여해 준다. 조건문에 크기별로 점수를 추가로 설정했던 것과 같이, 색상도 타입별로 다르게 지정한다.
Color 객체의 인자는 (Red, Green, Blue, Alpha)의 값을 0~1의 실수로 표현한다. 이를 위해 색상의 강도를 퍼센트로 나타내고, 1에 가까울수록 강도가 높다. renderer.color = new Color(100 / 255f, 100 / 255f, 1f, 1f)를 컴포넌트에서 나타내면 위 사진과 같다.
public class Rain : MonoBehaviour
{
float size;
int score;
SpriteRenderer renderer;
void Start()
{
float x = Random.Range(-2.4f, 2.4f);
float y = Random.Range(3.0f, 5.0f);
transform.position = new Vector3(x, y, 0);
renderer = GetComponent<SpriteRenderer>();
int type = Random.Range(1, 4);
if (type == 1)
{
size = 0.8f;
score = 1;
renderer.color = new Color(100 / 255f, 100 / 255f, 1f, 1f);
}
else if (type == 2)
{
size = 1.0f;
score = 2;
renderer.color = new Color(130 / 255f, 130 / 255f, 1f, 1f);
}
else
{
size = 1.2f;
score = 3;
renderer.color = new Color(150 / 255f, 150 / 255f, 1f, 1f);
}
transform.localScale = new Vector3(size, size, 0);
}
오브젝트의 위치, 크기, 점수, 색상을 모두 랜덤으로 부여하는 코드를 모두 합치면 위와 같다. 프로젝트를 실행할 때마다 오브젝트의 속성이 모두 랜덤하게 바뀌는 것을 확인할 수 있다.
반복
게임의 전반적인 진행을 위한 코드는 GameManager에 만든다.
이를 위해 하이어아키 빈 공간에서 우클릭 후 Create Empty로 GameManager 오브젝트를 생성한다. 프로젝트에서도 GameManager 스크립트를 생성하고 GameManager 오브젝트로 드래그 앤 드롭한다.
프리팹은 일종의 설계도로, 반복적으로 오브젝트를 생성할 수 있다. Rain을 프리팹으로 변환하여 Rain의 모든 컴포넌트를 하나의 틀로 만들고, 이 프리팹을 이용하면 동일한 오브젝트가 계속해서 생성된다.
Prefabs 폴더를 만들어서 Rain 오브젝트를 드래그 앤 드롭하면 상자가 파란색으로 변한다. 이후 하이어아키에서 Rain 오브젝트를 삭제해도 폴더 안의 Rain은 그대로 남아있다.
public class GameManager : MonoBehaviour
{
public GameObject rain;
void Start()
{
MakeRain();
}
void Update()
{
}
void MakeRain()
{
Instantiate(rain);
}
}
public 키워드를 통해 게임 오브젝트 타입의 rain 변수가 공개적으로 접근이 가능하며, 인스펙터에서도 조정할 수 있게 된다. public GameObject rain 코드를 실행하면 스크립트 컴포넌트에 Rain이 추가되는데, 여기에 Rain 오브젝트를 드래그 앤 드롭하면 rain 변수에 Rain 프리팹이 저장된다.
MakeRain()은 사용자 지정 메서드이고, 게임 오브젝트를 생성하는 Instantiate() 함수를 통해서 rain 변수의 게임 오브젝트를 복제한다. 그리고 Start 메서드에 MakeRain 메서드를 호출해서 게임이 시작될 때 rain 오브젝트가 생성되게 한다.
프로젝트를 실행하면 랜덤으로 Rain 오브젝트가 생성되고, 하이어아키에 Rain(Clone)이 생겼다가 충돌과 동시에 사라지는 것을 확인할 수 있다.
하지만 지금은 하나의 오브젝트만 생성되기 때문에, 반복해서 오브젝트가 생성되는 코드로 수정해야 한다.
public class GameManager : MonoBehaviour
{
public GameObject rain;
void Start()
{
InvokeRepeating("MakeRain", 0f, 1f);
}
void Update()
{
}
void MakeRain()
{
Instantiate(rain);
}
}
오브젝트가 반복해서 생성되게 하는 함수는 InvokeRepeating()으로, 직역하면 반복해서 호출한다는 의미를 가지고 있다.
인자값은 InvokeRepeating("메서드명", 처음 생성 시간, 반복 주기)이다. 메서드명은 string 타입으로 소괄호는 적지 않고 큰따옴표로 묶어주며, 시간과 반복 주기는 float 타입이므로 뒤에 f를 붙여야 한다.
위 코드에서는 MakeRain 메서드를 프로젝트 실행 0초 뒤 처음 호출하고, 이후 1초마다 반복한다는 의미를 갖고 있다.
프로그램을 실행하면 1초마다 오브젝트가 생성되고 충돌하여 사라지는 것을 확인할 수 있다.
[ 클래스 ]
클래스, 객체, 인스턴스
클래스는 속성과 메소드를 정의한 일종의 설계도이다. 객체는 클래스를 통해 생성된 결과물이다. 그리고 이러한 각각의 결과물들을 인스턴스라고 부른다.
마리오 설계도(클래스)는 콧수염 특성(속성)과 한쪽 팔을 드는 행동(메서드)을 가지고 있다.
이 설계도를 가지고 만든 실체화된 결과물인 마리오를 객체라고 한다.
설계도를 가지고 수많은 마리오를 만들 수 있는데, 이 설계도로 만들어진 모든 마리오들을 인스턴스라고 한다.
같은 설계도로 만들어졌지만, 이름 속성을 변화시켜서 각자 이름이 다른 인스턴스들을 만들 수도 있다.
클래스를 통해서 만들어진 결과물을 객체라고 하지만, 인스턴스는 특정 클래스를 통해서 만들어진 결과물을 의미하므로 일반적으로 두 용어는 동일하게 사용된다.
❓ 프리팹 vs 클래스
설명에서는 프리팹과 클래스 모두 일종의 설계도라고 했다. 그럼 프리팹과 클래스는 같은 것인가?
프리팹은 이미 만들어진 완전한 오브젝트를 반복해서 생성할 때 필요한 설계도이다.
오브젝트의 컴포넌트를 포함해서 복제하여, 동일한 오브젝트를 생성해야 할 때 사용한다.
예를 들어 정글맵에 똑같은 나무를 100개를 설치한다고 하면, 나무 오브젝트를 하나 생성하고 이를 프리팹화하여 빠르게 반복 생성할 수 있다.
프리팹을 수정하면, 복제한 다른 인스턴스에도 변경 사항이 자동으로 적용되어 관리가 편하다는 특징이 있다.
클래스는 코드를 통해 객체를 복제하는 데 필요한 설계도이다.
객체의 속성과 메서드와 같은 프로그래밍의 근본적인 구조를 정의한다.
예를 들어 마을에 NPC를 생성할 때, 각 NPC의 이름, 소속과 같은 특성이나 퀘스트 전달 등의 행동에만 변화를 주고 나머지는 모두 동일한 NPC 인스턴스를 생성할 수 있다.
public, private, void, atatic
public class Character
{
public string name;
public int health;
public static int totalCharacters = 0;
public Character(string name, int health)
{
this.name = name;
this.health = health;
totalCharacters++;
}
private void UpdateHealth(int change)
{
health += change;
}
}
위 코드는 캐릭터를 만드는 클래스 예시이다.
public은 프로젝트 어디에서나 접근이 가능하다는 것을 의미한다. Character 클래스의 인스턴스를 생성할 때, 해당 클래스 바깥에서 캐릭터의 name 속성과 health 속성을 가져오고 수정할 수 있다.
private는 클래스 내부에서만 접근이 가능하다는 것을 의미한다. UpdateHealth 메서드는 change 매개 변수의 정수값을 인자로 받아서 health 변수에 변화를 준다. 이 메소드는 클래스 내부에서는 접근하고 수정할 수 있지만, 외부에서는 불가능하다.
void는 메소드 호출 후 반환할 값이 없을 때 사용된다. 예를 들어 사칙 연산을 하는 계산기 클래스의 메소드는 두 값을 더한 뒤 값을 반환하지만, 위의 UpdateHealth 메소드는 health 변수에 변화만 주면 되고 굳이 값을 반환할 필요는 없기 때문에 메소드 앞에 void를 작성했다.
static은 클래스 내부에서 모든 인스턴스가 공유하는 하나의 값이다. 인스턴스가 생성될 때마다 totalCharacters 변수 값이 1씩 추가되고, 해당 클래스에서 하나로 공유되기 때문에 카운트가 계속해서 증가하게 된다.
non-static은 인스턴스를 생성할 때마다 각각의 값을 가지게 된다. totalCharacters 변수에 static 키워드가 없다면 캐릭터가 생성될 때마다 각자의 카운트 1을 보유하게 되고 토탈 카운트가 증가하지 않는다.
//static
public class GameSettings
{
public static int gameVolume = 100;
}
int currentVolume = GameSettings.gameVolume;
//non-static
public class GameSettings
{
public int gameVolume = 100;
}
GameSettings settings = new GameSettings();
int currentVolume = settings.gameVolume;
또한 static은 인스턴스를 생성하지 않고도 다른 곳에서 요소를 사용할 수 있게 한다.
예를 들어 다른 클래스에서 GameSettings 클래스의 gameVolume 변수를 가져오려고 한다. static 변수는 클래스에 종속되어 있기 때문에, 클래스를 통해서 바로 호출할 수 있다. 따라서 GameSettings 클래스의 인스턴스를 새롭게 생성하고 이 인스턴스를 통해 gameVolume 변수를 호출하는 수고를 덜 수 있다.
[ 회고 ]
요구되는 12시간을 넘어 3시간을 더 공부했음에도 불구하고, 오늘도 목표한 바를 완수하지 못했다. 기초 문법과는 다르게 게임개발 실전에서는 다양한 코드가 사용되고 있어서, 아직 강의 초반인 지금은 처음 보는 코드가 많았다. 해당 코드들은 기본이 되는 내용이고 앞으로도 계속해서 등장할 것이기 때문에, 이를 완벽하게 이해하고 넘어가는 것이 필요하다고 생각되어 기본적인 개념들을 공부하는 것에 충실했다.
이해하는 데 가장 몰입했던 개념은 클래스 파트였다. 객체와 인스턴스를 구분하는 것에 어려움이 있었고, 다양한 자료들을 찾아보며 시각화로 정리를 해서 내 것으로 만들 수 있을 때까지 공부했다. 또한 클래스에서 사용되는 다양한 키워드들을 이해하는 것에도 집중했다. 대강 느낌은 알았지만, 정확한 개념을 짚고 넘어가야 할 것 같아서 이 파트를 이해하는 것이 오늘 공부의 핵심이었다.
추가로 배웠던 점은 '왜 크기는 scale이 아닌 localScale을 사용할까?'와 같은 사소한 물음을 넘기지 않고 더 깊이 공부했을 때 큰 깨달음을 얻을 수도 있다는 것이었다. 처음에는 어렴풋이 속성이 다른가 보다 하고 생각했는데, 좌표계의 차이라는 원론적인 이유가 숨겨져 있었다.
지금은 속도가 좀 느릴지 몰라도 이런 기본적인 개념들을 모두 익히고 나면, 추후 강의를 수강할 때 큰 걸림 없이 수월하게 완강을 할 수 있을 것으로 기대된다.
'Coding > Unity' 카테고리의 다른 글
[내일배움캠프 17일차 TIL] 게임 엔진, PPU, 계층 구조, Input.GetAxis, 직렬화 (0) | 2024.05.08 |
---|---|
[내일배움캠프 5일차 TIL] 복습, 체력바, isKinematic, isTrigger, 레벨 (1) | 2024.04.19 |
[내일배움캠프 4일차 TIL] 복습, 마우스 포인터 추적, 점수 저장 (1) | 2024.04.18 |
[내일배움캠프 3일차 TIL] UI, 싱글톤, 점수 적용, 게임 오버, 다시 시작 (0) | 2024.04.17 |
[내일배움캠프 1일차 TIL] 오브젝트, 애니메이션, 스크립트, 캐릭터 움직이기 (0) | 2024.04.15 |