
1단계: 바닐라 좀비 vs 커스텀 몹, 그것이 문제로다
처음엔 두 가지 선택지가 있었음.
- 방법 1 (현재 방식): 걍 바닐라 좀비한테 '너 이제부터 기사임' 하고 데이터만 살짝 덮어씌우기.
- 방법 2 (내 선택): 아예 '해골 기마 좀비'라는 새로운 족보를 파서 등록하기.
난 '근본'을 중시하는 사람이므로 당연히 2번을 택했음. SkeletonHorsemanZombieEntity.java 라는 빛나는 파일을 만들고, Zombie를 상속받아 코딩을 시작함. 이게 모든 비극의 씨앗이었음.
2단계: 저주받은 NullPointerException
신나게 코드를 짜고 스폰 알을 딱! 클릭하는 순간.
GAME CRASHED
콘솔 창엔 피처럼 붉은 글씨가 떠 있었음.
Generated code
Caused by: java.lang.NullPointerException: Cannot invoke "net.minecraft.world.entity.ai.attributes.AttributeInstance.getValue()" because ... is null
뭔 소리냐면, "님, 이 좀비 스탯(체력, 공격력 등)이 등록 안 돼서 뭐 어쩌라는 건지 모르겠는데요?" 라는 뜻임.
나: "아니, 좀비를 상속했으니 당연히 좀비 스탯 따라가는 거 아님??"
마인크래프트: "응 아님ㅋ 니가 만든 커스텀 몹이면 니가 등록해야지ㅋ"
finalizeSpawn 메서드 안에서 부모 클래스 호출 순서를 바꿔보고, 별의별 짓을 다 해봤지만 결과는 똑같았음. 좀비 클래스는 생각보다 훨씬 예민하고 복잡한 아이였음.
3단계: 좀비 클래스와의 결별 선언
며칠간의 사투 끝에 깨달음을 얻음.
최종 원인: Zombie 클래스는 바닐라 로직(아기 좀비 변신, 주민 감염 등)이 너무 덕지덕지 붙어 있어서, 내가 하려는 '말 타기' 같은 커스텀 행동과 근본적으로 충돌함. 얘는 그냥 지 맘대로 살고 싶은 놈이었음.
결국 난 눈물을 머금고 extends Zombie를 지우고, 더 원시적인 조상인 extends Monster로 갈아탔음. 좀비의 복잡한 로직과 충돌할 일이 원천적으로 사라진 거임!
결과: 드디어 몹이 터지지 않고 세상에 스폰됨! 🎉
...인줄 알았는데.
4단계: 동족상잔의 비극 (feat. 우리 편끼리 왜 싸워?)
내 모드엔 이미 '다이아 좀비' 같은 다른 커스텀 좀비가 있었음.
근데 새로 만든 '해골 기마 좀비'가 다이아 좀비를 보자마자 활을 쏴 죽이는 거임. 심지어 다이아 좀비도 맞서 싸움. 아주 그냥 지들끼리 난리가 남.
"야! 너네 같은 모드 출신이잖아! 우리 편끼리 왜 싸워!"
instanceof로 하나하나 "너 다이아 좀비니? 그럼 때리지 마" 코드를 넣을 수도 있었지만, 앞으로 커스텀 몹 100개 만들면 if문 100줄 쓸 건가? 이건 아니었음.
5단계: 동맹 결성, 마커 인터페이스와 이벤트 버스
여기서 모딩의 신세계를 경험함.
- '팀 조끼' 입히기 (마커 인터페이스): 텅 빈 IAlly 라는 인터페이스를 만듦. 그리고 내 모든 커스텀 몹에게 이걸 implements 시킴. 이제 이 '팀 조끼'를 입고 있으면 우리 편이라는 표식이 생긴 거임.
- 심판 등판 (LivingHurtEvent): 마인크래프트에서는 누가 누굴 때리는 모든 순간이 '이벤트'로 방송됨. AllyDamageHandler 라는 심판을 만들어서 이 방송을 구독하게 함.
- 심판: "어? 누가 누구 때리려고 하네?"
- 심판: "때리는 놈이랑 맞는 놈 둘 다 IAlly 팀 조끼 입고 있나 확인해볼까?"
- 심판: "둘 다 입었네? 이번 공격은 무효! 데미지 0 처리!"
이걸로 끝난 줄 알았음. 근데 데미지는 0이 됐는데, 맞은 놈이 빡쳐서 반격은 하더라...
문제: 공격 AI는 '데미지'가 아니라 '공격당했다는 사실' 자체에 반응함.
그래서 공격 AI 자체를 뜯어고침.
Generated java
// 이런 느낌적인 느낌
new NearestAttackableTargetGoal<>(this, LivingEntity.class, true,
(target) -> !AllyDamageHandler.isAlly(target) // 목표가 우리 편이 아닐 때만 공격해라!
);
이 람다식 한 줄로 모든 게 해결됨. 이제 우리 편끼리는 서로 투명인간 취급함. 와... 이게 되네.
6단계: 유령 기수 (서버: 말 탔음, 클라: 안 탔는데?)
이제 진짜 다 된 줄 알고 스폰알을 클릭했는데...
웬 말 한 마리만 덩그러니 서 있고, 좀비는 저 멀리서 따로 놀고 있었음.
로그를 까보니 이런 경고가 뜸.
Generated code
[WARN] Received passengers for unknown entity
해석: "서버가 '좀비가 말에 탔다'고 신호를 보냈는데, 클라이언트(내 화면) 세상에는 아직 그 말이 존재하지 않아서 탑승 정보를 무시해버렸음."
서버와 클라이언트의 정보 처리 속도 차이, 즉 동기화 문제였음. 내가 스폰 이벤트를 너무 복잡하게 꼬아놔서 생긴 문제.
7단계: 최종보스는 스폰알이었다
이 동기화 문제를 해결하려고 별의별 짓을 다 함.
스폰알 클릭할 때 말과 기수를 한 번에 만들어서 소환도 해보고, 바닐라 스켈레톤 기수 로직도 따라 해보고, 심지어 "한 1초 기다렸다가 태우면 되지 않을까?" 같은 미친 생각도 함. (서버 멈춰서 절대 하면 안 됨)
근데 뭘 해도 기수는 활을 안 들고 있거나, 여전히 따로 놀았음.
진짜 포기 직전에 마지막으로 로그를 다시 봤음.
Generated code
[INFO] [FINALIZE_SPAWN] After super call. Held Item: air
이 한 줄이 모든 걸 말해주고 있었음.
진짜 최종 원인: 스폰알로 몹을 생성하는 특수한 경우, super.finalizeSpawn()을 호출해도 장비(활)가 제대로 장착되지 않는 현상이 있었던 거임. 부모 클래스가 파업한 거.
나: "야! 부모! 일 안 해?"
부모 클래스: (묵묵부답)
대단원: 그리고 찾아온 평화
원인을 알았으니 해결은 간단했음.
- (in finalizeSpawn): "오케이, 부모가 일 안 하면 내가 직접 하지." super.finalizeSpawn() 호출 바로 다음에, 장비 장착 코드(populateDefaultEquipmentSlots)를 내가 직접 한 번 더 호출해서 확인사살함.
- (in tick): 스폰 후, 첫 번째 tick(세상의 시간이 흐르는 첫 순간)에 말이 없으면 그제야 말을 만들어서 태움. 이러면 서버든 클라이언트든 몹이 세상에 완전히 자리 잡은 상태라 동기화 문제가 안 생김.
결과:
드디어, 마침내, 기어코.
스폰알을 클릭하자, 활을 든 좀비가 완벽하게 해골마에 올라탄 채로 위풍당당하게 나타났음.
우리 편을 공격하지도 않고, 다른 몹에게 어그로도 잘 끌었음.
요약 및 교훈
- 간단한 변형은 바닐라 재활용, 복잡한 건 새로 파자. 근데 웬만하면 새로 파는 게 속 편함.
- Zombie 클래스는 존중해주자. 웬만하면 상속하지 말자. 걔는 자유로운 영혼임.
- 아군/적군 구분은 '마커 인터페이스'와 '이벤트'가 국룰. if-instanceof 지옥에서 벗어나자.
- 서버와 클라이언트는 다른 세상에 산다. 스폰 로직이 복잡하면 동기화 문제는 반드시 터짐. 안전하게 tick()을 활용하자.
- 믿었던 부모 클래스에 발등 찍힐 수 있다. 로그를 끝까지 믿고, 안되면 내가 직접 하면 된다.
'모딩 > 마인크래프트 모드 개발 일지' 카테고리의 다른 글
| [마인크래프트 모딩]#14 주변 아군에게 광역 버프를 주는 '좀비 지휘관' (9) | 2025.06.29 |
|---|---|
| [마인크래프트 모딩]#13 삼지창을 퍼붓는 좀비 만들기 (9) | 2025.06.29 |
| [마인크래프트 모딩]#11 좀비힐러 (7) | 2025.06.28 |
| [마인크래프트 모딩]#10 모든 살아있는 생명을 공격하는 좀비 (4) | 2025.06.28 |
| [마인크래프트 모딩]#9 주민 ai수정의 어려움에 대해서 (9) | 2025.06.28 |