
1. 프로젝트 준비 및 기초 환경
- [Q&A] src 폴더 안에 README.txt를 생성해도 되나요? (자바 컴파일의 원리)
- 시스템 구조 개요: 아처(Archer) 모드를 구성하는 주요 클래스 역할 분석
- 데이터 관리(Capability), 엔티티 등록(Registry), 네트워크(Networking), 렌더링(Rendering)
2. 자바(Java) 심화 문법: 모딩 코드가 어려운 이유
- 함수형 프로그래밍: 람다식(() ->)을 이용한 지연 로딩(Lazy Loading) 이해하기
- 상수와 설계도: static final과 엔티티 타입 등록의 상관관계
- 코드의 간결함: 메소드 참조(::) 문법 뜯어보기
- 제네릭(Generics)의 늪:
- 와일드카드 <?>는 왜 쓰는 걸까?
- <? extends T>와 <? super T>의 차이 (읽기 전용 vs 쓰기 전용)
3. 데이터 영속성: NBT와 Capability 시스템
- 슬픈 시나리오: 내 아처의 레벨이 초기화되는 이유
- NBT(Named Binary Tag): 마인크래프트식 데이터 저장과 불러오기
- Capability 시스템: 엔티티에 커스텀 데이터(레벨, 킬 수) '부착'하기
4. 네트워크 통신: 서버와 클라이언트의 대화
- 왜 패킷(Packet)이 필요한가? 서버의 계산과 클라이언트의 화면 차이
- 통신 프로토콜 설계: ArcherNetwork와 전용 무전기 채널 구축
- 데이터의 직렬화: 객체를 바이트(Byte)로 쪼개고 다시 조립하는 과정
- 멀티스레딩의 주의점:
- 네트워크 스레드 vs 메인 스레드
- enqueueWork를 사용해야 하는 이유 (경쟁 상태 방지)
5. 시각적 구현: PoseStack과 HUD 렌더링
- PoseStack 원리: 투명 종이 비유로 배우는 좌표계 변형
- Push & Pop: 내 렌더링 코드가 다른 엔티티를 망가뜨리지 않게 하는 법
- 빌보드(Billboard) 기법: 어느 각도에서나 플레이어를 바라보는 텍스트 만들기
- 실시간 동기화: 패킷으로 받은 데이터로 체력바(HP Bar) 그리기
6. 엔티티 지능: 주민(Villager) AI와 센서 시스템
- 주민의 뇌(Brain) 구조: 센서(Sensor)와 기억(Memory) 모듈
- 전투 로직: VillagerSkirmishGoal – 적 인식과 카이팅(Kiting) 원리
- 도망의 과학: 좀비를 피하는 패닉(Panic) 행동 패키지와 경로 탐색(Pathfinding)
응 가능해.
src 폴더 안에 README.txt 만들어도 컴파일 에러 안 나.
이유를 설명하면.
자바 컴파일러는 .java 파일만 컴파일해.
.txt 파일은 그냥 일반 리소스 파일로 무시돼.
그래서 src 안에 있어도 영향 없어.
코드 해석해달라고하기
제공해주신 코드는 마인크래프트 Forge 모드에서 **'성장하는 아처(궁수) 주민 엔티티'**를 구현하기 위한 시스템입니다. 각 클래스의 역할을 기능별로 분류하여 설명해 드리겠습니다.
1. 데이터 및 시스템 관리 (Data & Capability)
- ArcherData: 아처의 핵심 데이터 저장소입니다. 아처의 레벨(level)과 처치 수(kills)를 저장하고, 이를 NBT(파일 저장 방식) 형식으로 변환하거나 불러오는 로직을 담당합니다.
- ArcherDataProvider: 마인크래프트의 Capability 시스템을 구현합니다. ArcherData를 엔티티에 "부착"하여 게임이 꺼져도 데이터가 유지되도록 연결해주는 다리 역할을 합니다.
2. 엔티티 정의 및 등록 (Entity & Registry)
- ArcherEntity: 실제 게임 안에 소환되는 아처 캐릭터 그 자체입니다. 주민(Villager)을 상속받았지만 거래 기능을 막아두었으며, 활을 들고 싸우도록 설정되어 있습니다.
- ArcherEntityTypeInit: 게임에 "아처"라는 새로운 엔티티 종류를 등록합니다. 크기(히트박스)와 종류를 설정합니다.
- ArcherItemInit: 아처를 소환할 수 있는 스폰 알(Spawn Egg) 아이템을 등록합니다.
3. 게임 로직 및 이벤트 (Events)
- ArcherEvents: 게임 내에서 일어나는 다양한 상황(이벤트)을 처리합니다.
- 아처의 기본 체력 설정.
- 아처가 몬스터를 죽였을 때 레벨업 시키기 (레벨업 시 최대 체력 증가 및 회복).
- 아처끼리 서로 쏘아도 데미지를 입지 않게 하는 '팀킬 방지'.
- 서버와 클라이언트 간의 데이터 동기화 요청.
4. 인공지능 (AI / Goal)
- VillagerSkirmishGoal: 아처의 전투 지능입니다. 적과의 거리를 유지(카이팅)하며 활을 쏘는 로직이 들어있습니다. 특이하게 아처의 레벨이 높을수록 화살에 관통(Piercing) 효과가 추가되도록 설계되어 있습니다.
5. 네트워크 (Networking)
- ArcherNetwork: 서버(계산)와 클라이언트(화면 표시) 사이의 통신 통로를 만듭니다.
- ClientboundArcherDataSyncPacket: 서버에서 업데이트된 아처의 레벨과 킬수 데이터를 클라이언트로 보내주는 실제 데이터 패킷입니다.
6. 시각적 구현 (Rendering)
- ArcherRenderer: 플레이어 화면에 아처의 정보를 그려줍니다. 아처의 머리 위에 **Lv, Kills 현황, 그리고 체력바(HP Bar)**를 띄워주는 UI 렌더링 역할을 수행합니다.
만약 들판이나 마을에서 자연적으로 스폰되게 하고 싶다면, ArcherEntityTypeInit이나 모드의 메인 클래스에서 SpawnPlacements 설정을 추가로 작성해야 합니다. (지금 코드에는 자연 스폰 설정은 포함되어 있지 않습니다.)
마인크래프트에서 **NBT(Named Binary Tag)**는 게임의 데이터를 하드디스크에 저장할 때 사용하는 **'메모장'**이나 '저장 파일' 같은 개념입니다.
작성하신 코드에서 이 데이터 저장 로직이 왜 중요한지, 그리고 어떻게 작동하는지 쉽게 풀어서 설명해 드릴게요.
1. 왜 NBT 저장이 필요한가요?
마인크래프트에서 엔티티(아처)는 게임을 끄거나, 멀리 가서 청크가 언로드(Unload)되면 메모리에서 사라집니다. 만약 NBT 저장 로직이 없다면 다음과 같은 일이 발생합니다.
- 슬픈 시나리오: 열심히 몬스터를 잡아 레벨을 10까지 올린 아처가 있는데, 게임을 껐다 켜니 다시 레벨 1이 되어버립니다.
이를 방지하기 위해 **"데이터를 파일로 저장(Save)"**하고 **"다시 불러오기(Load)"**하는 과정이 필요합니다.
2. 코드 분석: 데이터를 파일로 쓰기 (Save)
ArcherData 클래스의 이 부분입니다.
public void saveNBTData(CompoundTag nbt) {
nbt.putInt("archer_level", level); // "archer_level"이라는 이름표를 붙여서 level 값을 저장
nbt.putInt("archer_kills", kills); // "archer_kills"라는 이름표를 붙여서 kills 값을 저장
}
- 역할: 아처가 가지고 있는 level 변수(예: 5)와 kills 변수(예: 3)를 CompoundTag라는 이름의 데이터 상자에 집어넣는 과정입니다.
- 이 상자는 마인크래프트가 월드 파일을 저장할 때 하드디스크에 같이 저장됩니다.
3. 코드 분석: 파일에서 데이터 읽기 (Load)
public void loadNBTData(CompoundTag nbt) {
if (nbt.contains("archer_level")) { // 상자 안에 "archer_level"이라는 이름표가 있는지 확인
level = nbt.getInt("archer_level"); // 이름표가 있다면 그 값을 꺼내서 현재 level 변수에 대입
kills = nbt.getInt("archer_kills"); // kills 값도 꺼내서 대입
}
}
- 역할: 게임을 다시 켰을 때, 하드디스크에 저장된 상자(NBT)를 열어서 예전에 저장했던 값을 꺼내오는 과정입니다.
- 이 덕분에 게임을 껐다 켜도 아처는 예전 레벨을 그대로 유지하게 됩니다.
4. 코드 분석: 연결 다리 (Provider)
ArcherDataProvider는 마인크래프트 시스템이 "어이, 이 아처 엔티티의 데이터를 저장할 시간이다!"라고 신호를 보낼 때 호출되는 통로입니다.
@Override
public CompoundTag serializeNBT() {
// 저장할 때: 새 상자를 만들어서 ArcherData에게 "여기 담아줘"라고 시킴
CompoundTag nbt = new CompoundTag();
createArcherData().saveNBTData(nbt);
return nbt;
}
@Override
public void deserializeNBT(CompoundTag nbt) {
// 불러올 때: 상자를 받아서 ArcherData에게 "이거 읽고 너의 상태를 업데이트해"라고 시킴
createArcherData().loadNBTData(nbt);
}
약간, 폰에서 습관어플에서 json이랑 비슷한것같네
이 코드는 자바의 기초 문법을 넘어, **자바 8 이후의 현대적 문법(람다, 제네릭)**과 마인크래프트 포지(Forge) 특유의 시스템이 결합된 형태입니다.
어려워 보일 수 있는 부분들을 문법별로 하나씩 쪼개서 설명해 드릴게요.
1. () -> ... (람다식 - Lambda Expression)
가장 헷갈릴 수 있는 부분입니다.
() -> new ForgeSpawnEggItem(...)
- 문법 명칭: 람다식 또는 익명 함수
- 의미: "지금 당장 아이템 객체를 만드는 게 아니라, 나중에 필요할 때 이 코드를 실행해서 아이템을 만들어라"라는 명령서를 전달하는 것입니다.
- 왜 이렇게 하나요? 마인크래프트는 아이템을 등록하는 정해진 순서와 시간이 있습니다. 게임이 켜지자마자 아이템을 바로 만들어버리면 오류가 날 수 있기 때문에, 포지(Forge)에게 "아이템 만드는 방법은 이렇고, 네가 준비됐을 때 이 방법대로 만들어줘"라고 예약을 걸어두는 것입니다. (이를 '지연 로딩'이라고 합니다.)
2. <Item> (제네릭 - Generics)
RegistryObject<Item>
- 문법 명칭: 제네릭
- 의미: RegistryObject라는 상자 안에 들어있는 내용물이 Item 타입이라는 것을 명시합니다.
- 나중에 ARCHER_SPAWN_EGG.get()을 호출했을 때, 별도의 형변환 없이 바로 Item으로 꺼내 쓸 수 있게 해줍니다.
3. new Item.Properties().tab(...) (메서드 체이닝 - Method Chaining)
new Item.Properties().tab(CreativeModeTab.TAB_MISC)
- 문법 명칭: 메서드 체이닝 또는 빌더 패턴
- 의미: 객체를 생성하면서 설정을 연달아 붙이는 방식입니다.
- new Item.Properties(): "아이템 속성 설정서를 새로 만든다."
- .tab(...): "그 설정서에 '기타 탭에 넣어줘'라고 적는다."
- 점(.)으로 계속 연결해서 속성을 추가하는 현대 자바의 흔한 스타일입니다.
4. 0x8C6B4F (16진수 리터럴)
- 문법 명칭: 16진수 정수
- 의미: 프로그래밍에서 색상을 표현할 때 주로 사용합니다.
- 0x는 "이 뒤에 나오는 숫자는 16진수다"라는 뜻입니다.
- 8C6B4F는 RGB(빨강, 초록, 파랑) 색상 값입니다.
5. public static final (제어자)
- static: 이 아이템 등록 정보는 메모리에 딱 하나만 존재하며, 어디서든 접근 가능하다.
- final: 한 번 등록하면 중간에 다른 아이템으로 바꿀 수 없다(상수).
전체 문장을 말로 풀어서 해석하면 이렇습니다:
"포지 시스템(ITEMS)에 'archer_spawn_egg'라는 이름으로 등록(register)할 건데, 나중에 준비되면 내가 준 방법(() -> new ...)대로 아이템을 생성해줘. 이 정보는 ARCHER_SPAWN_EGG라는 변수에 안전하게 담아두고 나중에 꺼내 쓸게!"
이 방식은 포지에서 아이템이나 블록을 등록할 때 사용하는 표준 공식 같은 것이니, "람다를 이용한 예약 등록 방식"이라고 이해하시면 가장 정확합니다.
1. static final의 진짜 의미 (스폰알이 2개면 안 되나?)
사용자님의 추측이 어느 정도 맞습니다! 하지만 정확히는 **"스폰알이라는 '아이템의 정의'가 바뀌거나 중복되면 안 되기 때문"**입니다.
- static: 게임 전체에서 이 스폰알 아이템 정보는 단 하나만 있으면 됩니다. 모든 아처 스폰알 아이템 객체들이 "나의 속성은 이거야"라고 참조할 수 있는 공용 이정표 같은 역할입니다.
- final: 한 번 ARCHER_SPAWN_EGG에 아처 스폰알 정보를 할당하고 나면, 중간에 갑자기 사과나 흙 블록으로 바뀌지 못하게 락(Lock)을 거는 것입니다.
- 아이템이 2개 존재하면 안 되나?: 플레이어 인벤토리에 스폰알이 10개 있을 수는 있습니다. 하지만 그 10개의 아이템이 "우리는 모두 '아처 스폰알'이야"라고 가리키는 **원형(Prototype)**은 오직 이 static final 변수 하나뿐이어야 합니다.
2. ArcherEntityTypeInit.ARCHER (객체를 만드는 건가?)
이건 궁수 객체(Entity)를 직접 만드는 게 아니라, "궁수라는 엔티티의 종류(타입)" 정보를 전달하는 것입니다.
- 예를 들어, 붕어빵 틀을 주는 것과 같습니다. 스폰알한테 **"너를 사용하면 'ARCHER'라는 틀을 사용해서 엔티티를 찍어내면 돼"**라고 알려주는 참조값입니다. 실제 엔티티 객체(ArcherEntity)는 나중에 사용자가 알을 땅에 클릭하는 순간에 생성됩니다.
3. 왜 람다(() ->)를 써야 할까? (타이밍의 문제)
이게 가장 핵심입니다! 사용자님이 생각하신 "나중에 사용될 때 나오도록"이라는 개념과 비슷하지만, 대상이 다릅니다. 엔티티가 아니라 '아이템 객체' 그 자체의 생성 타이밍 때문입니다.
- 람다를 안 쓰면?: 게임이 켜지자마자 자바는 new ForgeSpawnEggItem(...)을 즉시 실행해서 아이템 객체를 만들려고 합니다.
- 문제 발생: 하지만 마인크래프트 포지는 아이템을 만들기 전에 먼저 처리해야 할 내부 작업들이 아주 많습니다. 준비도 안 됐는데 아이템을 억지로 만들려고 하면 **게임이 켜지다가 크래시(에러)**가 납니다.
- 람다의 역할: () -> new ...라고 쓰면 **"지금 당장 만들지 말고, 나중에 포지 네가 '자, 이제 아이템들 등록하자!'라고 신호 줄 때 그때 이 코드를 실행해줘"**라고 포장지를 씌워두는 것입니다.
4. 람다 없이 자바로 구현한다면? (과거의 방식)
람다 문법이 없던 시절(자바 8 이전)에는 **"익명 내부 클래스(Anonymous Inner Class)"**라는 아주 복잡한 방식을 썼습니다.
사용자님이 말씀하신 "유니티의 구독 버튼(델리게이트/이벤트)" 개념과 아주 흡사합니다! 코드로 비교해 드릴게요.
[현재 방식 (람다)]
() -> new ForgeSpawnEggItem(...)
[옛날 방식 (익명 클래스)]
new Supplier<Item>() {
@Override
public Item get() {
return new ForgeSpawnEggItem(...);
}
}
- 차이점: 위 두 코드는 컴퓨터 입장에서는 완전히 똑같습니다. 단지 람다가 나오면서 저 길고 복잡한 new Supplier... @Override...를 () ->라는 짧은 기호로 생략할 수 있게 된 것입니다.
- 구현 원리: if문으로 구현하는 게 아니라, **"나중에 실행할 코드를 객체에 담아서 전달"**하는 방식입니다. 포지가 이 상자(Supplier)를 들고 있다가, 적절한 때에 get() 버튼을 꾹 누르면 그제야 아이템이 튀어나오는 식이죠.
요약
- static final: 게임 통틀어 이 아이템의 **'정의'**는 변하지 않는 딱 하나여야 하니까!
- ARCHER: 실제 아처가 아니라, 아처를 만들 때 사용할 **'설계도'**를 넘겨준 것!
- 람다: 게임이 시작될 때 너무 일찍 아이템이 만들어져서 에러 나는 걸 막기 위해 "나중에 포지 네가 부를 때 만들어!"라고 예약해둔 것!
- 원리: 유니티의 콜백(Callback)이나 이벤트 구독처럼, **"실행할 동작을 상자에 담아 전달"**하는 자바식 방법입니다.
(오, 컴퓨터 네트워크 배우고 나니깐 뭔소린지 좀더 알수있을것같네 )
마인크래프트에서 **패킷(Packet)**이 필요한 이유는 간단합니다. **서버(게임의 두뇌)**는 아처가 레벨업했다는 걸 알고 있지만, **클라이언트(플레이어의 화면)**는 서버가 말해주기 전까지는 그 사실을 모르기 때문입니다.
전달해주신 스크립트들(ArcherNetwork, ClientboundArcherDataSyncPacket, ArcherEvents)이 어떻게 협력하여 데이터를 주고받는지 단계별로 해석해 드릴게요.
1. 통신 통로 개설: ArcherNetwork
이 클래스는 서버와 클라이언트 사이에 전용 **"무전기 채널"**을 만드는 역할을 합니다.
- INSTANCE: 우리 모드만 사용하는 전용 무전기 채널입니다. 다른 모드와 통신이 섞이지 않게 archer_channel이라는 이름을 붙입니다.
- register(): "앞으로 ClientboundArcherDataSyncPacket이라는 이름의 편지를 주고받을 거야"라고 등록하는 과정입니다. 이때 편지를 어떻게 접고(toBytes) 어떻게 펴는지(생성자)를 등록합니다.
2. 편지의 내용물: ClientboundArcherDataSyncPacket
이 클래스는 실제로 주고받는 "편지(데이터 뭉치)" 그 자체입니다.
- 내용물 (데이터): entityId(어떤 아처인지), level(현재 레벨), kills(현재 킬수).
- toBytes (편지 쓰기): 자바 객체 형태인 데이터를 네트워크 전송이 가능한 **0과 1의 이진 데이터(ByteBuf)**로 변환합니다. (서버에서 실행)
- FriendlyByteBuf 생성자 (편지 읽기): 도착한 이진 데이터를 다시 숫자(int)로 변환합니다. (클라이언트에서 실행)
- handle (동작 실행): 편지를 읽은 후의 행동입니다.
- 클라이언트 월드에서 해당 ID를 가진 아처 엔티티를 찾습니다.
- 그 아처의 ArcherData를 편지에 적힌 최신 값(레벨, 킬수)으로 업데이트합니다.
- 결과: 이제 클라이언트의 ArcherRenderer가 머리 위에 "Lv.2"라고 정확히 그릴 수 있게 됩니다.
3. 편지를 보내는 타이밍: ArcherEvents
언제 패킷을 보낼지 결정하는 "우체부" 역할입니다.
- 상황 1: 레벨업 시 (onLivingDeath)
- 아처가 몬스터를 죽여서 서버에서 레벨이 올랐을 때, 주변의 모든 플레이어에게 "이 아처 레벨 올랐어!"라고 패킷을 쏴줍니다.
- 상황 2: 처음 만났을 때 (onStartTracking)
- 멀리 있던 플레이어가 아처 근처로 다가와서 아처가 화면에 보이기 시작할 때, 서버가 "이 아처는 지금 레벨이 이래"라고 정보를 알려줍니다. 그래야 처음 만난 아처의 머리 위에 정보가 바로 뜹니다.
전체 흐름 요약 (시나리오)
- [서버] 아처가 좀비를 죽였습니다. 킬수가 찼고 레벨이 1에서 2로 오릅니다.
- [서버] ArcherEvents가 감지합니다. "어? 레벨 올랐네? 클라이언트들한테 알려줘야지."
- [서버] ArcherNetwork 채널을 통해 ClientboundArcherDataSyncPacket을 발송합니다.
- [네트워크] 데이터가 인터넷(또는 로컬 통신)을 타고 플레이어의 컴퓨터로 날아갑니다.
- [클라이언트] 패킷의 handle 메서드가 실행됩니다. 아처를 찾아서 데이터를 2레벨로 바꿉니다.
- [클라이언트] ArcherRenderer가 매 프레임 화면을 그릴 때, 이제 업데이트된 데이터를 보고 머리 위에 **"Lv.2"**를 출력합니다.
왜 이 복잡한 짓을 하나요?
마인크래프트에서 **엔티티의 커스텀 데이터(Capability)**는 기본적으로 서버에만 존재합니다. 패킷을 직접 만들지 않으면, 아처가 아무리 몬스터를 잡아도 플레이어 화면에는 계속 레벨 1로 보이거나, 체력바가 움직이지 않는 현상이 발생하기 때문입니다.
1. 이게 TCP 방식인가요?
네, 맞습니다. 마인크래프트의 모든 게임 데이터 통신은 TCP를 기반으로 작동합니다. (정확히는 자바의 네트워크 라이브러리인 'Netty'를 사용하여 TCP 연결을 관리합니다.)
2. TCP를 직접 구현해야 하나요?
아니요, 그럴 필요 없습니다.
네트워크 수업에서 배우신 3-way handshake, 흐름 제어, 오류 정정 같은 복잡한 하위 로직은 마인크래프트 엔진과 Forge 프레임워크가 이미 다 구현해두었습니다.
지금 작성하신 코드는 OSI 7계층으로 치면 최상위인 **응용 계층(Application Layer)**의 로직입니다. 즉, "어떻게 연결할까?"가 아니라 **"연결된 통로에 어떤 데이터를 실어 보낼까?"**를 결정하는 코드입니다.
3. 각 스크립트 하나씩 쉽게 설명
① ArcherNetwork.java (무전기 채널 설정)
컴퓨터 네트워크로 치면 **"포트(Port)를 열고 통신 규약을 정하는 것"**과 같습니다.
- INSTANCE: 우리 모드 전용 무전기 채널입니다. 이름(archer_channel)을 붙여서 다른 모드의 데이터와 섞이지 않게 합니다.
- register(): 이 채널에서 사용할 "데이터 형식"을 등록합니다.
- "자, 이제부터 0번 데이터가 오면 이건 ClientboundArcherDataSyncPacket이야. 읽는 법은 이렇고, 실행은 이렇게 해!"라고 미리 약속하는 것입니다.
② ClientboundArcherDataSyncPacket.java (데이터 패킷 정의)
이것은 실제 **"패킷의 헤더와 페이로드(내용물)"**를 설계하는 곳입니다.
- entityId, level, kills: 우리가 보내고 싶은 실제 데이터(Payload)입니다.
- toBytes (직렬화/Serialization): 자바 객체(데이터)를 네트워크 선을 타고 갈 수 있는 **바이트(0과 1)**로 쪼개는 과정입니다.
- new (FriendlyByteBuf) (역직렬화/Deserialization): 반대로 받은 바이트들을 다시 숫자로 조립하는 과정입니다.
- handle: 데이터가 무사히 도착했을 때 실행할 콜백 함수입니다. "데이터 왔으니 화면에 반영해!"라고 지시합니다.
③ ArcherEvents.java (패킷 송신 트리거)
패킷을 "언제(When)" 보낼지 결정하는 부분입니다.
- onLivingDeath: 아처가 몬스터를 죽였을 때(이벤트) 실행됩니다.
- 서버: "오, 레벨업했네? 패킷 만들어서 클라이언트한테 쏴!"
- onStartTracking: 플레이어가 아처 근처로 와서 아처가 렌더링되기 시작할 때 실행됩니다.
- 서버: "새로운 플레이어가 아처를 보기 시작했어. 현재 아처 상태를 패킷으로 알려줘야지!"
1. :: 문법의 정체: 메소드 참조 (Method Reference)
가장 궁금해하신 ::는 자바 8에서 도입된 **'메소드 참조'**라는 문법입니다.
- 뜻: "이 메소드를 지금 당장 실행해!"가 아니라, **"나중에 필요할 때 이 메소드를 사용해!"**라고 함수 자체를 전달하는 방식입니다.
- 람다식과의 비교:
- 람다 방식: (packet, buffer) -> packet.toBytes(buffer) (패킷과 버퍼를 받아서 toBytes를 실행해라)
- 메소드 참조: ClientboundArcherDataSyncPacket::toBytes ("이 패킷의 toBytes라는 '설명서'를 통째로 넘겨줄게")
- 왜 쓰나요? 코드가 훨씬 간결해지고 읽기 편해지기 때문입니다.
2. 코드 한 줄씩 뜯어보기
① 채널 생성 (INSTANCE)
public static final SimpleChannel INSTANCE = NetworkRegistry.newSimpleChannel(
new ResourceLocation(MyMod.MODID, "archer_channel"), // 채널의 고유 주소
() -> PROTOCOL_VERSION, // 현재 내 무전기 버전 (1)
PROTOCOL_VERSION::equals, // 서버 버전이 내 버전(1)과 같은가?
PROTOCOL_VERSION::equals // 클라 버전이 내 버전(1)과 같은가?
);
- 네트워크 수업에서 배우신 '프로토콜 정의' 단계입니다. 버전이 서로 다르면 아예 통신을 거부하도록 설정하는 안전장치입니다.
음, 프로토콜 자체를 내가 정의할수있나
페이로드에 어떤 데이터가 들어갈지?
② 패킷 등록 (register())
이 부분이 핵심입니다. 무전기 채널에 **"어떤 종류의 편지를 보낼 것인가"**를 등록합니다.
INSTANCE.registerMessage(nextId(), // 1. 패킷의 고유 ID (0, 1, 2...)
ClientboundArcherDataSyncPacket.class, // 2. 어떤 클래스인가?
ClientboundArcherDataSyncPacket::toBytes, // 3. 인코더: 보낼 때 바이트로 어떻게 바꾸지? (:: 문법)
ClientboundArcherDataSyncPacket::new, // 4. 디코더: 받았을 때 바이트를 어떻게 다시 조립하지? (생성자 참조)
ClientboundArcherDataSyncPacket::handle, // 5. 핸들러: 도착하면 어떤 행동을 하지? (:: 문법)
Optional.of(NetworkDirection.PLAY_TO_CLIENT) // 6. 방향: 서버 -> 클라이언트로만 가는 편지다!
);
- 여기서 ::를 사용하여 포지(Forge)에게 **"인코딩할 때는 이 메소드를 쓰고, 디코딩할 때는 이 생성자를 써"**라고 가르쳐주는 것입니다.
③ 패킷 보내기 (sendToPlayer)
public static <MSG> void sendToPlayer(MSG message, ServerPlayer player) {
INSTANCE.send(PacketDistributor.PLAYER.with(() -> player), message);
}
- <MSG>: 제네릭 문법입니다. "어떤 종류의 패킷(메시지)이든 보낼 수 있다"는 뜻입니다.
- PacketDistributor.PLAYER: "모든 사람이 아니라, 특정 플레이어 1명에게만 귓속말을 보내겠다"는 설정입니다.
페이로드(Payload)에 어떤 데이터가 들어갈지?
네트워크 수업에서 페이로드는 헤더를 제외한 실제 전송하고자 하는 알맹이 데이터라고 배우셨을 겁니다.
이 코드에서 페이로드는 ClientboundArcherDataSyncPacket.java의 toBytes 메소드에서 결정됩니다.
[사용자님의 페이로드 구조 분석]
사용자님이 작성하신 toBytes 코드를 보면 페이로드가 어떻게 구성되는지 알 수 있습니다:
public void toBytes(FriendlyByteBuf buf) {
buf.writeInt(entityId); // [4바이트] 어떤 아처인지 (ID)
buf.writeInt(level); // [4바이트] 현재 레벨 (Level)
buf.writeInt(kills); // [4바이트] 잡은 몬스터 수 (Kills)
}
- 결과적으로 생성되는 페이로드: 총 12바이트의 바이너리 데이터입니다.
- 구조: [00 00 00 05] [00 00 00 02] [00 00 00 01] (예: ID 5번, 레벨 2, 킬 1인 경우)
만약 페이로드를 바꾸고 싶다면?
"아처의 이름(String)도 보내고 싶다!" 한다면 프로토콜을 이렇게 수정하면 됩니다:
- 클래스에 String name 변수 추가
- toBytes에 buf.writeUtf(name) 추가 (페이로드에 데이터 추가)
- 생성자(읽는 부분)에 this.name = buf.readUtf() 추가 (순서에 맞춰 읽기)
네트워크 이론과의 아주 중요한 연결 (Serialization)
네트워크 수업에서 가장 강조하는 것 중 하나가 **"바이트 오더(Byte Order)"**와 **"직렬화(Serialization)"**입니다.
- 직렬화: 자바 메모리에 있는 int level = 10이라는 객체를 네트워크 선을 타고 갈 수 있게 00 00 00 0A 같은 바이트로 바꾸는 과정입니다. 이게 바로 toBytes입니다.
- 동기화의 핵심: TCP는 바이트의 흐름(Stream)만 보내줍니다. 따라서 보내는 순서와 받는 순서가 1비트라도 틀리면 데이터가 오염(Corruption)됩니다.
- 보낼 때: ID -> Level -> Kills 순으로 썼다면,
- 받을 때: 반드시 ID -> Level -> Kills 순으로 읽어야 합니다.
handle 메소드의 정체: "편지를 읽은 후의 행동 지침"
네트워크를 통해 패킷(바이트 뭉치)이 도착하고, 아까 보셨던 ::new 생성자가 바이트를 읽어 이 msg 객체를 만들었습니다. 이제 **"이 데이터를 가지고 실제 게임에서 무엇을 할 것인가?"**를 정하는 단계입니다.
1. 스레드 = 일꾼
우리가 프로그램을 실행하면, 그 프로그램 안에는 여러 명의 **일꾼(스레드)**이 동시에 일을 할 수 있어요.
- 일꾼 A (메인 스레드): "나는 화면을 그려야 해! 몬스터를 움직이고 햇빛을 비춰야지!" (가장 바쁘고 중요한 일꾼)
- 일꾼 B (네트워크 스레드): "나는 밖에서 오는 전화를 받아야 해! 패킷이 왔나 안 왔나만 감시할 거야." (대기조)
2. 왜 일꾼이 여러 명인가요?
만약 일꾼이 딱 한 명이라면 어떻게 될까요?
전화를 받는 동안(패킷 처리)에는 화면을 그릴 수 없어서 게임이 멈춰버려요. (렉이 걸리는 거죠.) 그래서 "전화 받는 놈"과 "게임 화면 그리는 놈"을 따로 두는 거예요. 이게 바로 멀티 스레드입니다.
3. 스레드가 싸우는 이유 (공유 메모리)
여기서 문제가 생겨요. 이 일꾼들은 **한 집(프로그램 메모리)**에서 같이 살아요. 그래서 같은 가구(데이터)를 같이 써요.
- 상황: "아처의 레벨"이라는 상자가 하나 있어요.
- 일꾼 B (네트워크): 패킷을 받고 신나서 "어! 레벨 2로 바꿔야지!" 하고 상자를 열려고 해요.
- 일꾼 A (메인): "나는 지금 화면 그려야 하니까 이 아처 레벨이 몇인지 읽어야 해!" 하고 상자를 열려고 해요.
두 일꾼이 동시에 상자를 잡고 잡아당기면? 상자가 망가지겠죠? 이걸 전문 용어로 **경쟁 상태(Race Condition)**라고 하고, 프로그램이 "에라 모르겠다!" 하고 꺼져버리는(Crash) 원인이 돼요.
4. 마인크래프트에서의 스레드 이야기
마인크래프트는 아주 고집이 센 **메인 일꾼(Main Thread)**이 살고 있어요.
이 일꾼은 **"게임 데이터(아처의 레벨 등)는 무조건 나만 건드릴 수 있어!"**라고 규칙을 정해놨어요.
그래서 아까 본 handle 메소드에서 이런 일이 일어난 거예요.
- 네트워크 일꾼이 패킷(레벨업 소식)을 들고 왔어요.
- 하지만 자기가 직접 아처의 레벨을 고치면 메인 일꾼이랑 싸우게 된다는 걸 알아요.
- 그래서 **enqueueWork (냉장고 메모지)**를 써서 "메인 일꾼아, 나중에 한가할 때 네가 이 아처 레벨 좀 2로 고쳐줘~"라고 쪽지를 남기고 자기 할 일을 하러 가는 거예요.
- 나중에 메인 일꾼이 화면 한 장 다 그리고 잠깐 쉴 때 쪽지를 보고 "오케이, 내가 고칠게" 하고 안전하게 레벨을 바꾸는 거죠.
자바의 **멀티스레드(Multi-thread)**는 '일꾼'에 대한 이야기이고, 자료구조의 **스레드 이진 트리(Threaded Binary Tree)**는 '실뜨기(연결선)'에 대한 이야기입니다.
자바에서 <?>는 **와일드카드(Wildcard)**라고 부릅니다. 제네릭을 사용할 때 "무슨 타입인지 정확히 모르겠지만, 일단 모든 타입을 다 받을 수 있게 하겠다"는 의미죠.
질문하신 대로 **"타입을 확정 지으려고 제네릭을 쓰는데, 왜 다시 모호하게 <?>를 쓰느냐?"**는 의문이 드는 게 당연합니다. 그 이유와 핵심적인 차이를 설명해 드릴게요.
1. <?> (와일드카드)의 의미
List<?>라고 하면 **"어떤 타입의 리스트인지는 모르겠지만, 아무튼 리스트다"**라는 뜻입니다.
- List<Object>: "Object 객체를 담는 리스트" (어떤 객체든 넣을 수 있음)
- List<?>: "무언가 특정 타입이 정해져 있는 리스트인데, 그 타입이 뭔지 지금은 모름"
2. 왜 쓰는 걸까? (존재 이유)
가장 큰 이유는 **"타입에 의존하지 않는 범용적인 코드"**를 만들기 위해서입니다.
예시: 리스트의 내용을 출력하는 메서드
만약 모든 종류의 리스트를 출력하고 싶다면 어떻게 해야 할까요?
// 1. List<Object>를 사용하면?
public void printList(List<Object> list) {
for (Object obj : list) System.out.println(obj);
}
// 실행 시 에러 발생!
List<String> names = Arrays.asList("Kim", "Lee");
printList(names); // 컴파일 에러! List<String>은 List<Object>의 자식이 아님.
자바 제네릭에서 List<String>은 List<Object>에 할당할 수 없습니다. 타입 안정성 때문이죠.
// 2. List<?>를 사용하면?
public void printList(List<?> list) {
for (Object obj : list) System.out.println(obj);
}
// 성공!
List<String> names = Arrays.asList("Kim", "Lee");
List<Integer> nums = Arrays.asList(1, 2, 3);
printList(names); // OK
printList(nums); // OK
<?>를 사용하면 내용물이 무엇이든 상관없이 리스트라는 구조 자체를 다루는 로직을 짤 수 있습니다.
3. <?>를 쓰면 생기는 제약 (중요!)
"모든 타입을 다 받으니까 만능 아니야?"라고 생각할 수 있지만, 큰 제약이 있습니다. 데이터를 추가할 수 없습니다.
List<?> list = new ArrayList<String>();
list.add("test"); // 컴파일 에러!
왜 안 될까요? 자바 입장에서는 list가 ArrayList<Integer>일지 ArrayList<String>일지 알 수 없기 때문에, 아무 데이터나 넣었다가 타입이 꼬이는 것을 방지하려고 추가(add)를 막아버립니다. (꺼내기만 가능)
4. 진짜 의미는 '제한된 와일드카드'에서 나옵니다
단순히 <?>만 쓰기보다는 보통 범위를 제한할 때 진가를 발휘합니다.
- <? extends T> (상한 제한): T와 그 자식들만 가능 (읽기 전용에 특화)
- <? super T> (하한 제한): T와 그 부모들만 가능 (쓰기 전용에 특화)
예를 들어, List<? extends Number>라고 하면 "숫자형(Integer, Double 등) 리스트라면 뭐든 다 받을게"라는 뜻이 됩니다.
요약: 제네릭 <?> 쓰는 의미
- 다형성 활용: 특정 타입에 얽매이지 않고, 제네릭 객체(List, Map 등)를 매개변수로 받아 공통적인 로직을 수행하고 싶을 때.
- 유연성: List<String>은 List<Object>에 못 들어가지만, List<?>에는 들어갈 수 있음.
- 안전성: 타입이 뭔지 확실치 않을 때는 데이터를 함부로 추가하지 못하게 막아 데이터의 무결성을 지킴.
즉, **"내용물의 타입은 관심 없고, 이 제네릭 구조(리스트 등) 자체를 안전하게 다루고 싶다"**는 의도로 사용한다고 보시면 됩니다!
1. super가 뭐지? (상속의 반대 방향
extends가 나를 포함해서 내 밑으로(자식들) 내려가는 거라면,
super는 나를 포함해서 내 위로(부모들) 올라가는 겁니다.
- extends Number: Number, Integer, Double, Float... (Number의 자식들)
- super Integer: Integer, Number, Object (Integer의 부모들)
2. "왜 extends T인데 T도 못 넣나요?" (핵심 질문)
이게 제일 억울하게 느껴지는 부분이죠. 예시를 들어볼게요.
List<? extends Number> list = new ArrayList<Integer>(); // 이것은 가능합니다.
위 코드에서 list는 분명 Number를 상속받은 무언가의 리스트입니다. 여기서는 실제 정체가 ArrayList<Integer>죠.
이제 여기에 Double을 넣는다고 해봅시다.
list.add(3.14); // 에러 발생!
자바의 논리: "어? list는 Integer 리스트인데, 왜 Double을 넣어? 그러면 안 돼!"
그럼 질문하신 대로 **"그럼 Integer를 넣으면 되잖아요?"**라고 할 수 있죠?
list.add(10); // 이것도 에러 발생!
자바의 논리: "이 list의 실제 정체가 ArrayList<Double>일 수도 있잖아! 나는 지금 이 리스트의 정확한 타입이 뭔지 몰라. 그냥 Number 자식 중 하나라는 것만 알아. 그래서 사고 날까 봐 아무것도(Integer든 Double이든) 못 넣게 할 거야!"
결론: <? extends T>는 "실제 타입이 무엇으로 정해져 있는지 모르기 때문에" 안전을 위해 쓰기(add)를 금지하는 것입니다.
3. "아무것도 못 넣으면 소용없지 않나요?" (용도)
넣지도 못하는 걸 왜 쓰냐 하면, "이미 만들어진 리스트를 읽을 때" 엄청난 힘을 발휘하기 때문입니다.
예를 들어, 리스트에 있는 모든 숫자를 더해서 출력하는 메서드를 만든다고 해봅시다.
public void printSum(List<? extends Number> list) {
double sum = 0;
for (Number n : list) {
sum += n.doubleValue();
}
System.out.println(sum);
}
이 메서드는 이제 무적입니다.
- List<Integer>를 넘겨줘도 잘 돌아갑니다.
- List<Double>을 넘겨줘도 잘 돌아갑니다.
- List<Float>을 넘겨줘도 잘 돌아갑니다.
만약 List<? extends Number>가 아니라 그냥 List<Number>라고 썼다면, List<Integer>는 들어오지도 못하고 컴파일 에러가 났을 겁니다. (자바에서 List<Number>와 List<Integer>는 남남이기 때문이죠.)
즉, extends의 목적은 "다양한 자식 타입의 리스트들을 한꺼번에 받아서 읽기 위해" 사용하는 것입니다.
4. 그럼 데이터를 넣고 싶을 땐? super를 씁니다.
데이터를 넣는 게 목적이라면 <? super T>를 씁니다.
List<? super Integer> list = new ArrayList<Number>();
list.add(10); // 성공!
자바의 논리: "list가 뭔지는 몰라도 최소한 Integer보다는 큰 타입(Number나 Object)의 리스트겠네? 그럼 Integer를 넣는 건 항상 안전하니까 허용해 줄게!"
최종 요약
- **extends**는 "읽기 전용" 셔틀입니다.
- 다양한 자식 리스트를 받아오고 싶을 때 씁니다.
- 대신, 정확한 타입을 모르니 추가(add)는 못 하게 막습니다.
- **super**는 "쓰기 전용" 셔틀입니다.
- 해당 타입과 그 자식들을 안전하게 리스트에 담고 싶을 때 씁니다.
- 의미: "제네릭을 썼는데 왜 또 와일드카드를 써?"라는 질문의 답은 "유연함" 때문입니다. List<Integer>만 받는 메서드보다, Number의 자식이면 뭐든 다 받는 메서드가 훨씬 쓸모가 많으니까요!
이 코드는 클라이언트 화면에서 **아처 엔티티의 머리 위에 정보(레벨, 킬 수, 체력바)를 렌더링(출력)**하는 로직을 담당합니다. 그래픽 라이브러리인 OpenGL을 추상화한 마인크래프트의 렌더링 시스템을 사용합니다.
주요 메소드와 로직을 기술적으로 분석하겠습니다.
1. 클래스 설정 및 이벤트 구독
- @Mod.EventBusSubscriber: 이 클래스가 이벤트를 수신 대기함을 선언합니다.
- value = Dist.CLIENT: 렌더링은 화면이 있는 클라이언트에서만 필요하므로 물리적 서버에서는 로드되지 않게 제한합니다.
- bus = Bus.FORGE: 엔티티 렌더링 이벤트는 Forge 버스에서 발생합니다.
- RenderLivingEvent.Post<LivingEntity, ?>: 엔티티 본체의 렌더링이 끝난 직후에 호출되는 이벤트입니다. 본체 위에 추가 정보를 덧그리기 위해 사용합니다.
2. 필터링 로직 (초반 if문)
if (!(event.getEntity() instanceof ArcherEntity archer)) return;
Minecraft mc = Minecraft.getInstance();
if (mc.player == null || archer.distanceToSqr(mc.player) > 256) return;
- 타입 체크: 현재 렌더링 중인 엔티티가 ArcherEntity가 아니면 즉시 종료합니다.
- 거리 체크: distanceToSqr(mc.player) > 256는 플레이어와의 거리가 16블록(16^2 = 256)보다 멀면 최적화를 위해 정보를 그리지 않겠다는 뜻입니다.
3. 좌표계 변환 (Matrix Manipulation)
머리 위 허공에 글자를 띄우기 위해 **PoseStack**을 사용하여 좌표계를 조작합니다.
- poseStack.pushPose(): 현재의 좌표 상태(위치, 회전, 크기)를 저장합니다.
- poseStack.translate(0, archer.getBbHeight() + 0.5F, 0): 렌더링 위치를 엔티티의 발바닥에서 머리 위로 0.5블록 높입니다.
- poseStack.mulPose(mc.getEntityRenderDispatcher().cameraOrientation()): 빌보드(Billboard) 효과입니다. 플레이어가 어느 방향에서 보더라도 글자가 항상 플레이어 정면을 향하게 회전시킵니다.
- poseStack.scale(-0.025f, -0.025f, 0.025f): 마인크래프트의 1단위는 1블록이므로, 그대로 글자를 쓰면 너무 거대합니다. 이를 아주 작게 축소하여 적절한 크기로 만듭니다. (음수 값은 텍스처의 뒤집힘을 방지하기 위함입니다.)
4. 정보 렌더링 (Text & GUI)
① 텍스트 출력 (font.drawInBatch)
- levelText, killText, healthText를 생성하여 지정된 상대 좌표에 그립니다.
- -font.width(text) / 2.0f: 텍스트의 가로 길이를 계산해 중앙 정렬을 수행합니다.
- packedLight: 엔티티가 처한 환경 밝기에 맞춰 글자 밝기를 조절합니다.
② 체력바 출력 (GuiComponent.fill)
이 메소드는 지정된 영역을 특정 색상으로 채우는 사각형(Rectangle)을 그립니다.
// 배경(검은색 띠)
GuiComponent.fill(poseStack, -barWidth/2 - 1, yOffset - 1, barWidth/2 + 1, yOffset + 5, 0xFF000000);
// 체력(녹색 바)
int healthBarWidth = (int) ((health / maxHealth) * barWidth);
GuiComponent.fill(poseStack, -barWidth/2, yOffset, -barWidth/2 + healthBarWidth, yOffset + 4, 0xFF00FF00);
- 배경: 검은색 사각형을 먼저 그려 테두리 효과를 줍니다.
- 체력 비례 계산: (현재 체력 / 최대 체력) * 전체 가로 폭 공식으로 현재 체력만큼만 녹색 바가 차오르게 계산합니다.
5. 마무리
- poseStack.popPose(): 아처의 머리 위 정보를 그리기 위해 변형했던 좌표계를 다시 원래대로 되돌립니다. (이걸 안 하면 이후에 그려질 다른 엔티티들이 엉뚱한 위치에 그려지게 됩니다.)
요약
이 클래스는 매 프레임마다 실행되며, [좌표 이동 -> 카메라 방향으로 회전 -> 크기 조절 -> 글자 및 사각형 드로잉] 과정을 거쳐 아처 전용 HUD(Head-up Display)를 게임 화면에 구현합니다.
**PoseStack**의 동작 원리를 이해하려면, 마인크래프트가 그림을 그릴 때 사용하는 **'도화지'와 '기준점'**의 개념을 알아야 합니다.
컴퓨터 그래픽에서 모든 그림은 **(0, 0, 0)**이라는 기준점을 바탕으로 그려집니다. 비유를 통해 아주 쉽게 설명해 드릴게요.
1. 비유: 투명 종이 겹치기 (레이어)
마인크래프트의 렌더링 시스템은 하나의 거대한 도화지에 모든 것을 그리는 게 아니라, 도화지 위에 투명한 비닐 종이를 한 장씩 얹어서 그리는 것과 같습니다.
- 기본 상태: 현재 도화지의 기준점(0, 0, 0)은 아처의 발바닥에 맞춰져 있습니다.
- pushPose(): "지금의 깨끗한 상태를 기억해둬!" 하고 새로운 투명 비닐 종이를 한 장 위에 덧대는 것입니다.
- 좌표 변형 (translate, mulPose, scale):
- 이제 우리는 이 새 비닐 종이만 이리저리 옮깁니다.
- 머리 위로 올리고(translate),
- 플레이어를 보게 돌리고(mulPose),
- 글씨 크기를 줄입니다(scale).
- 중요: 이 모든 난리법석은 방금 덧댄 비닐 종이 위에서만 일어납니다. 밑에 있는 원래 도화지는 아직 가만히 있습니다.
- 그리기 (font.draw...): 변형된 비닐 종이 위에 레벨과 체력바를 그립니다.
- popPose(): "자, 이제 다 그렸으니 이 비닐 종이는 버려!" 하고 덧댔던 비닐을 확 걷어내는 것입니다.
- 그러면 다시 아처의 발바닥이 기준점이었던 원래의 깨끗한 도화지 상태로 돌아옵니다.
2. 만약 popPose()를 안 하면 어떻게 될까?
컴퓨터는 바보라서, 우리가 비닐을 걷어내지 않으면 **그다음 엔티티(예: 옆에 서 있는 소)**를 그릴 때도 아까 그 "작고, 돌아가 있고, 머리 위에 떠 있는" 상태의 종이 위에 계속 그리려고 합니다.
- 정상적인 경우 (pop 했을 때):
- 아처 머리 위에 정보를 그림 -> pop 해서 원래대로 복구.
- 옆에 있는 **소(Cow)**를 그릴 때 다시 소의 발바닥부터 정상적으로 시작.
- pop을 안 했을 때 (대참사):
- 아처 머리 위에 정보를 그림 -> 좌표계가 작아진 상태 그대로 남음.
- 다음 순서인 소를 그리려고 하는데, 기준점이 이미 **"아처의 머리 위 허공"**에 있고 크기도 **"0.025배"**로 줄어든 상태임.
- 결과: 소가 아처의 머리 위에서 개미보다 작게 찌그러진 채로 허공에 둥둥 떠서 그려지게 됩니다.
3. 기술적인 요약 (스택 구조)
이름이 PoseStack인 이유는 자료구조의 **스택(Stack)**을 사용하기 때문입니다.
- pushPose(): 현재의 위치/회전/크기 행렬(Matrix)을 복사해서 스택의 맨 위에 쌓습니다. (LIFO: Last-In, First-Out)
- 좌표 변형: 스택의 맨 위에 있는 행렬만 수정합니다.
- popPose(): 작업을 다 마친 후, 수정된 맨 위 행렬을 **제거(Pop)**해버립니다.
- 그러면 스택에는 수정되기 전의 이전 행렬이 다시 맨 위로 올라오게 되어, 완벽하게 이전 상태로 복구되는 원리입니다.
한 줄 요약:
내가 한 위치 이동이나 크기 조절이 다음 차례에 그려질 다른 물체들에게 전염되지 않도록 "여기까지가 끝이야!"라고 선을 긋는 작업입니다.
1. ArcherEntity: "아처의 몸과 뇌" (직접적인 AI)
이 스크립트는 **"나는 누구인가? 나는 지금 무엇을 해야 하는가?"**를 정의합니다.
- 몸 (정의): Villager를 상속받아 주민의 외형을 가집니다. 손에 활을 들고 있도록 설정합니다.
- 뇌 (AI - registerGoals): 여기서 아처의 모든 행동 패턴(Goal)이 결정됩니다.
- VillagerSkirmishGoal: 이게 진짜 전투 AI입니다. "적과 거리를 유지하며 활을 쏴라"라는 지능을 부여합니다.
- FloatGoal: "물에 빠지면 허우적거려라" (생존 AI)
- NearestAttackableTargetGoal: "몬스터(Monster)를 보면 적으로 간주해라" (타겟팅 AI)
- 특징: 주민을 상속받았지만, isTrading() { return false; } 등을 통해 거래 기능은 삭제했습니다. (싸우기만 하는 주민을 만들기 위해)
2. ArcherEvents: "세상의 규칙과 보상" (시스템 관리)
이 스크립트는 아처가 게임 속에서 살아갈 때 발생하는 **사건(이벤트)**들을 처리합니다. AI가 "공격하겠다"고 마음먹는다면, 이 스크립트는 **"공격 성공 후 레벨이 올랐을 때 어떤 일이 벌어지는가"**를 관리합니다.
- 능력치 설정 (onEntityAttributeCreation): 아처가 처음 태어날 때 체력이 얼마인지 설정합니다.
- 데이터 연결 (onAttachCapabilities): 아처에게 "레벨"과 "킬수"를 저장할 수 있는 메모리(데이터 상자)를 붙여줍니다.
- 팀킬 방지 (onFriendlyFireCheck): 아처끼리 서로 쏴도 데미지를 입지 않게 규칙을 만듭니다.
- 레벨업 시스템 (onLivingDeath): 가장 중요한 부분입니다.
- 아처가 몬스터를 죽였는지 확인합니다.
- 맞다면 킬수를 올리고, 레벨을 올립니다.
- 레벨이 오르면 최대 체력을 늘려주고 피를 채워줍니다.
- 이 사실을 패킷으로 클라이언트에게 알려줍니다. (그래야 우리 화면에 레벨업된 게 보입니다.)
상황: 아처가 좀비를 발견하고 화살을 쏴서 잡았습니다.
| 단계 | 실행되는 곳 | 역할 |
| 1. 좀비를 발견함 | ArcherEntity (AI) | "적이 보이네? 공격 목표로 설정하자!" (타겟팅) |
| 2. 거리를 조절하며 쏨 | ArcherEntity (AI) | "너무 가까우면 위험하니까 뒤로 가면서 쏴야지!" (카이팅) |
| 3. 화살이 좀비에 맞음 | 마인크래프트 엔진 | 물리 연산 및 데미지 계산 |
| 4. 좀비가 죽음 (사건 발생) | ArcherEvents (시스템) | "이벤트 발생! 아처가 죽였으니 킬수를 1 올리고 레벨업 시켜라!" |
| 5. 체력이 늘어남 | ArcherEvents (시스템) | "레벨이 올랐으니 최대 체력 수치를 다시 계산해서 적용해라!" |
1. 좀비를 어떻게 인식하는가? (Sensor 시스템)
주민이 주변의 위협(좀비 등)을 인식하는 것은 코드 중간의 SENSOR_TYPES와 MEMORY_TYPES 덕분입니다.
- SENSOR_TYPES: 주민의 '감각 기관'입니다.
- SensorType.VILLAGER_HOSTILES: 주민에게 적대적인 몹(좀비, 일레이저 등)을 찾아내는 감서입니다.
- SensorType.NEAREST_LIVING_ENTITIES: 주변의 모든 생명체를 감지합니다.
- MEMORY_TYPES: 감지한 정보를 '기억'에 저장합니다.
- MemoryModuleType.NEAREST_HOSTILE: 가장 가까운 적대적 존재를 기억에 저장합니다.
- MemoryModuleType.HURT_BY: 누구에게 공격당했는지 기억합니다.
작동 방식: 센서가 주변을 스캔하여 좀비를 발견하면, 그 정보를 주민의 Brain에 있는 "가장 가까운 적" 기억 저장소에 넣습니다.
2. 어떻게 도망가는가? (Activity & Behavior)
인식은 센서가 하지만, 실제 움직임은 Activity.PANIC과 관련이 있습니다.
- registerBrainGoals 메소드:이 줄이 중요합니다. 주민의 뇌(Brain)에 **"패닉 상태일 때 어떻게 행동할지(Panic Package)"**를 등록하는 것입니다. 좀비가 근처에 있거나 공격을 받으면 주민의 상태가 IDLE에서 PANIC으로 전환됩니다.
p_35425_.addActivity(Activity.PANIC, VillagerGoalPackages.getPanicPackage(villagerprofession, 0.5F)); - 도망 알고리즘 (카이팅 및 좌표 계산):
이 코드가 참조하는 VillagerGoalPackages.getPanicPackage 내부에서는 보통 좀비의 반대 방향으로 좌표를 계산하여 이동하거나, 가장 가까운 집(침대)이나 종이 있는 곳으로 도망치도록 경로(Path)를 탐색합니다.
3. 문 뒤에 좀비가 있어도 도망가나?
- 시야 제한: 대부분의 센서는 Line of Sight(시야)를 체크합니다. 즉, 좀비와 주민 사이에 불투명한 블록(문 포함)이 있으면 주민은 좀비를 시각적으로 인식하지 못할 수도 있습니다.
- 소리/거리 인식: 하지만 센서 설정에 따라 시야가 확보되지 않아도 일정 거리(보통 8~12블록 내외) 안에 좀비가 있으면 "위협"을 느끼고 도망갈 수 있습니다.
- 문의 특징: 주민은 ((GroundPathNavigation)this.getNavigation()).setCanOpenDoors(true); 설정 때문에 문을 열고 도망칠 수 있습니다. 좀비가 문 앞에 있다면 주민은 문을 열려다가 좀비를 보고 다시 반대 방향으로 도망가는 등의 행동을 보입니다.
4. 지하와 지상의 좌표 계산 (수직 거리)
주민의 AI는 **3차원 좌표(X, Y, Z)**를 모두 사용하지만, 주로 경로 탐색(Pathfinding) 기반입니다.
- 인식 범위: 센서의 범위는 구 형태입니다. 좀비가 주민의 바로 발밑 지하 5블록 아래에 있다면, 센서 범위 안에는 들어오지만 **"경로상으로 닿을 수 있는가"**를 판단합니다.
- 지하/지상:
- 만약 지하에 좀비가 있고 주민은 지상에 있는데, 좀비가 주민에게 올 수 있는 통로가 없다면 주민은 패닉에 빠지지 않을 때가 많습니다.
- 하지만 마인크래프트 AI는 Pathfinding을 통해 "이 적이 나에게 올 수 있는 길인가?"를 계산하며, 만약 좀비가 너무 가까이 붙어있다면(벽 하나를 사이에 두고) 시야가 없어도 PANIC 상태가 활성화되기도 합니다.
5. 코드의 핵심 요약
- 두뇌 중심 AI: 주민은 Villager 클래스 자체에 if(zombie) run() 같은 단순한 코드가 들어있는 게 아니라, Brain이라는 복잡한 시스템에 의해 **감지(Sensor) -> 기억(Memory) -> 행동(Activity)**의 단계를 거칩니다.
- 경로 탐색: 도망갈 때는 단순히 좀비의 반대 좌표로 가는 게 아니라, GroundPathNavigation을 사용하여 블록의 높낮이와 문을 고려한 실제 "걸어갈 수 있는 길"을 찾습니다.
- 철고렘 소환: 좀비 인식을 통해 쌓인 기억(GOLEM_DETECTED_RECENTLY)과 평판(gossips)을 바탕으로 spawnGolemIfNeeded를 실행하여 철고렘을 소환하기도 합니다.
결론적으로: 주민이 좀비를 피하는 방식은 "내 시야에 들어왔는가?" 혹은 **"내가 데미지를 입었는가?"**를 센서로 판단한 뒤, 미리 짜인 **'패닉 행동 패키지'**를 실행하여 안전한 좌표(주로 마을 중심이나 집 안)로 길을 찾아가는 방식입니다. 문이나 지하 여부는 경로 탐색 알고리즘에서 장애물 또는 거리 비용으로 계산됩니다.
'모딩 > 마인크래프트 모드 개발 일지' 카테고리의 다른 글
| 마크 모딩) juggernaut도 새로운 스폰알로 구현 (0) | 2025.12.21 |
|---|---|
| 마크모딩) 도망만 다니던 주민을 전투 요원으로 (1) | 2025.12.21 |
| 마크 모딩) 커스포지에 업로드 (0) | 2025.12.20 |
| 마크 모딩) 커스포지에 업로드하기전 사전준비들 (2) | 2025.12.20 |
| [마인크래프트 모딩]#20 전방에 충격파를 날리는 주민 (15) | 2025.07.06 |