

📑 목차
1. 엔티티 AI 및 타겟팅 로직 (AI & Targeting)
- 1.1 선제 공격(Pre-emptive Attack) 메커니즘
- 몬스터 인식 및 선제 타격 구현 원리 (NearestAttackableTargetGoal)
- 인식 범위(FOLLOW_RANGE) 설정과 시야 확보의 중요성
- 1.2 타겟 식별 시스템 개선
- 단순 isMonster 판별의 한계점 및 개선 방향
- 인터페이스(IZombieAlly, IVillagerAlly)를 활용한 세력 구분 및 타겟팅
- 아군 오사 방지를 위한 인터페이스 기반 필터링 로직
- 1.3 특수 공격 및 행동 패턴
- 원거리 공격 및 거리 유지 로직 (VillagerSkirmishGoal)
- 저격수 특성: 초장거리 공격 구현
- 근접 시 대응 로직 및 도망 AI 설정
2. 투사체 시스템 (Projectile System)
- 2.1 투사체 제작 파이프라인
- 1단계: 블록벤치(Blockbench)를 이용한 모델링 및 방향 설정(-Z축)
- 2단계: AbstractArrow 상속을 통한 엔티티 클래스 구현
- 3단계: 렌더러(Renderer) 연결 및 회전(Yaw/Pitch) 값 처리
- 2.2 시각 효과 및 파티클 처리
- tick() 메서드를 활용한 비행 잔상 파티클 생성
- 커스텀 파티클 등록 및 텍스처 연결 프로세스
- 2.3 투사체 물리 및 판정 최적화
- 중력 제어(setNoGravity) 및 속도 조절
- 충돌 시 즉시 소멸(discard) 처리를 통한 무적 시간(I-frame) 이슈 해결
- 유도탄(Homing Projectile) 구현 이론 및 타겟 참조(Reference) 방식
3. 성능 최적화 및 서버 로직 (Performance & Internal)
- 3.1 공간 분할 시스템: 섹션(Section) 탐색
- EntitySectionStorage 분석: 왜 전수 조사가 필요 없는가?
- Spatial Hashing 기반의 엔티티 관리 원리
- 3.2 AI 부하 분산 처리
- randomInterval을 이용한 탐색 주기 최적화
- 레이트레이싱(시야 체크) 연산의 후순위 배치 전략
- 3.3 대량의 엔티티 처리 시 성능 유지 비결
4. 모드 아키텍처 및 상속 구조 (Architecture)
- 4.1 최상위 부모 클래스 설계
- IMyModEntity 및 AbstractAllyEntity의 역할
- 레벨업 시스템, HUD 표시, 데이터 저장 로직의 공통화
- 4.2 독립적인 엔티티 확장 (V3 개발)
- 오버라이딩(Overriding)을 통한 부모 기능의 선택적 수용
- 의존성 최소화 및 개별 능력치 설정
- 4.3 관리자 도구 시스템
- 관리자 검(Admin Sword)의 강제 처형 원리 (hurt 메서드 오버라이딩)
- 이벤트 핸들러를 활용한 엔티티 수정 최소화 방안
5. 개발 현황 및 주요 이슈 해결 (Dev Log & Bug Fix)
- 5.1 [완료] 주요 구현 항목
- 기본 투사체 발사 및 렌더링 시스템
- 프로토타입 상속 구조 설계 및 레벨업 시스템 초안
- 5.2 [해결] 주요 버그 및 오류
- 속성 등록 중복 에러(Duplicate DefaultAttributes) 해결
- 투사체 잔상 및 다중 타격 판정 이슈 해결
- 5.3 [진행/검토] 향후 과제
- 물속 엔티티 타격 불가 문제 분석
- 투사체 유도 로직 적용 및 성능 테스트
- 엔티티 구분 방식 고도화 및 세력 인터페이스 확장
[ ]걸어다니는 애니메이션
(법사라서 굳이 안해도될것같기도하고 )
[ ]지팡이,
[ ]마법 쏠때마다, 팔 움직이도록 하기
[ ]법사, 좀비 isZombie근처에 있으면 선체 타격하도록
[ ]
엔티티 수정 최소화: 각 법사나 좀비 클래스마다 hurt 메서드를 일일이 오버라이딩하면 코드가 지저분해집니다. AdminKillHandler처럼 이벤트 핸들러를 사용하면
이벤트 핸들러가 뭐지
[ ]
선제공격하는지 안하는지 문제
일반좀비로 실험해볼까나
[ ]
현재 물속에 있는건 공격 못하는 문제
일단 투사체가 어떻게 작동하는지 알아야겟는데
[ ]단순히 isMonster인지로 말고,
더 나은방법으로 구분하도록
된건가
[ ]법사니깐, 순간이동하게 할수도
[ ]아니면 벨코즈처럼, 눈만 잇는 엔티티도 가능할지도
[ ]코드적으로 어떻게 이게 화면에 나오게 되는거지
대충 블록정보를 코드로 구현된게 한거같긴한네
투사체 부분
[ ]관리자검 기능 잘되는지
- 관리자 검 검사: 부모의 규칙(hurt 메서드) 안에는 **"나를 때린 놈이 관리자 검을 들고 있는가?"**를 확인하는 코드가 있습니다.
- 강제 처형: 관리자 검임이 확인되면, 법사의 남은 체력이 얼마든 상관없이 데미지를 **무한대(Float.MAX_VALUE)**로 뻥튀기해서 적용합니다.
걍 kill()메소드 호출하는거 아니엇나 [ ]
[ ]저격수특성
:아주 먼거리에서도 공격 가능하도록
[ ]
마치 신드라나, 이렐리아처럼,
6개의 구체가 법사 근처에 있도록할수도뭔가 초월상태가 되면,
- IMyModEntity (최상위 부모 인터페이스): "내 모드 소속"임을 증명하는 신분증.
- ㄴ IVillagerAlly (자식): 아군 주민들 (법사, 궁수 등).
- ㄴ IZombieAlly (자식): 아군 좀비들.
[ ]이 상속구조, 굳이 아군은 아니지만, 모드몹인 경우도 잇을수잇으니깐
[ ok ]일단, 레벨업 기능이나,
추가기능을 첨부터 때려붙지말고
프로토콜들에서 실험한뒤,
엄선된거를 넣자
[ ok ]뭔가 상속구조로 만들어서 더 다양한 법사들 만들어보고 싶은데,
일단 프로토타입단계에서는 너무 많은 기능을 한법사 넣으면
(가령 레벨업해야 나온다거나 하게 해야하면, 성능확인할때도 시간이 걸리고 그래서 )
[ ok ]투사체

머리위에 히트박스있긴한데
일단 이정도는 패스하자
1단계: 블록벤치(Blockbench)에서 모델링
네, 맞습니다. 우선 투사체의 '형체'가 필요합니다.
- 모델링: 너무 크지 않게 만드세요 (보통 0.5x0.5x0.5 블록 이내).
- 팁: 마법 화살 같은 형태도 좋고, 단순히 작은 정육면체 여러 개를 겹친 '마법 구체' 형태도 좋습니다.
- 텍스처: 밝고 빛나는 느낌의 텍스처를 추천합니다.
- 내보내기: Java Entity Model로 내보냅니다. (예: MagicMissileModel)
2단계: 투사체 엔티티 클래스 만들기 (AbstractArrow 상속)
마인크래프트에서 날아가는 물체는 모두 '엔티티'입니다. 화살의 기능을 그대로 쓰면서 외형만 바꾸는 게 가장 쉽습니다.
- 클래스 생성: MagicProjectileEntity 클래스를 만듭니다.
- 특징: 중력의 영향을 받을지, 벽에 박힐지, 아니면 벽에 닿자마자 터질지를 결정합니다.
3단계: 날아가는 이펙트 (파티클) 처리
**"날아가는 이펙트는 따로 만들어야 하나요?"**에 대한 답은 **"코드로 처리한다"**입니다.
블록벤치에서는 '모양'만 만들고, 날아갈 때 뒤에 남는 잔상(연기, 불꽃, 빛나는 가루)은 엔티티의 tick() 메서드에서 **파티클(Particle)**을 생성하여 만듭니다.
// Entity 클래스 내부의 tick 메서드 예시
@Override
public void tick() {
super.tick();
if (this.level.isClientSide) {
// 매 틱마다 내 위치에 마법 가루 파티클을 소환 (이것이 날아가는 이펙트가 됨)
this.level.addParticle(ParticleTypes.WITCH, this.getX(), this.getY(), this.getZ(), 0, 0, 0);
}
}
4단계: 등록 및 렌더러 연결 (Registry & Renderer)
마법사 엔티티를 등록했던 것과 똑같은 과정이 필요합니다.
- EntityType 등록: WizardEntityTypeInit에 투사체 타입을 추가합니다.
- Renderer 등록: 투사체 모델과 텍스처를 그려줄 MagicProjectileRenderer를 만듭니다.
5단계: 마법사 AI 수정 (발사 코드 변경)
마지막으로, 기존에 작성했던 WizardSkirmishGoal의 performSingleShot 메서드에서 Arrow 대신 우리가 만든 MagicProjectileEntity를 소환하도록 코드를 바꿉니다.
// WizardSkirmishGoal.java 수정 부분
private void performSingleShot() {
// 기존: AbstractArrow arrow = new Arrow(...)
// 변경:
MagicProjectileEntity magicMissile = new MagicProjectileEntity(this.wizard.level, this.wizard);
magicMissile.shoot(dx, dy, dz, 1.6F, 1.0F); // 발사!
this.wizard.level.addFreshEntity(magicMissile);
}
1단계: 파티클 텍스처 만들기 (.png)
먼저 파티클로 사용할 이미지를 만듭니다.
- 위치: src/main/resources/assets/[modid]/textures/particle/ 폴더
- 크기: 보통 8x8 또는 16x16 픽셀의 투명 PNG 파일이면 충분합니다.
- 예: magic_star.png (별 모양 마법 입자)
2단계: 파티클 데이터 정의 (JSON)
마인크래프트에게 이 파티클이 어떤 텍스처를 사용하는지 알려줘야 합니다.
- 위치: src/main/resources/assets/[modid]/particles/ 폴더
- 파일 이름: magic_star.json (텍스처 이름과 동일하게)
- 내용:
{ "textures": [ "mymod:magic_star" ] }
3단계: 파티클 타입 등록 (Java Code)
이제 게임 엔진에 "이런 파티클이 존재한다"라고 등록해야 합니다. (이건 딱 한 번만 짜두면 됩니다.)
public class ModParticles {
public static final DeferredRegister<ParticleType<?>> PARTICLE_TYPES =
DeferredRegister.create(ForgeRegistries.PARTICLE_TYPES, MyMod.MODID);
// 우리가 만든 파티클의 등록 이름
public static final RegistryObject<SimpleParticleType> MAGIC_STAR =
PARTICLE_TYPES.register("magic_star", () -> new SimpleParticleType(true));
}
4단계: 클라이언트 측 연결 (ParticleProvider)
파티클이 실제로 화면에 어떻게 보일지(색깔 변화, 중력 영향 등) 결정하는 부분입니다. 여기서 사용자 정의가 들어갑니다.
- 크기 조절: 파티클이 점점 커지게 하거나 작아지게 할 수 있습니다.
- 수명: 몇 초 동안 나타났다 사라질지 정합니다.
- 움직임: 빙글빙글 돌거나, 위로 솟구치게 할 수 있습니다.
5단계: 투사체에 적용하기
이제 투사체 엔티티 코드에서 우리가 만든 파티클을 소환하도록 설정하면 끝입니다!
// 투사체 클래스 내부
@Override
public void tick() {
super.tick();
if (this.level.isClientSide) {
this.level.addParticle(ModParticles.MAGIC_STAR.get(),
this.getX(), this.getY(), this.getZ(), 0, 0, 0);
}
}
💡 더 높은 수준의 커스텀을 원하신다면? (코드 최소화 방법)
만약 텍스처 한 장이 아니라, **"화려하게 터지고, 휘어지고, 빛나는 이펙트"**를 코드 없이 만들고 싶다면 이런 도구들을 사용하기도 합니다:
- Effekseer (이펙시어): 전문 이펙트 제작 툴입니다. 마인크래프트 모드 중 Effekseer for Forge 라이브러리를 사용하면, 툴에서 만든 화려한 이펙트 파일(.efkefc)을 그대로 게임에 불러올 수 있습니다. (가장 퀄리티가 높지만 공부가 필요함)
- GeckoLib (게코립): 모델 애니메이션뿐만 아니라 투사체에도 애니메이션을 넣을 수 있습니다. 투사체 자체가 막 변신하거나 회전하는 등의 연출이 가능합니다.
화살처럼 앞(촉)과 뒤(깃)가 명확한 투사체를 만들 때 가장 중요한 것은 **"모델의 방향"**과 **"코드에서의 회전 처리"**입니다.
1. 블록벤치(Blockbench)에서 모델링할 때의 방향
가장 먼저 해야 할 일은 블록벤치에서 모델을 만들 때 **"어느 쪽이 앞인가"**를 정하는 것입니다.
- 규칙: 마인크래프트 엔티티 모델링에서 기본 '앞(Forward)' 방향은 보통 North (북쪽, -Z 축) 방향입니다.
- 방법:
- 화살촉(뾰족한 부분)이 -Z 방향(블록벤치 화면 하단의 좌표계를 확인하세요)을 향하도록 만드세요.
- 투사체가 수평으로 날아가는 모습이 기본형이어야 합니다.
- 중심축(Pivot Point)을 모델의 중앙 혹은 앞부분에 두어야 회전할 때 자연스럽습니다.
2. 엔티티 코드에서의 처리 (방향 계산)
투사체가 날아가는 방향(속도 벡터)을 보고 엔티티의 **Yaw(좌우 회전)**와 **Pitch(상하 회전)**를 계산해야 합니다.
만약 AbstractArrow를 상속받아 투사체를 만든다면, 마인크래프트가 이미 내부적으로 다음과 같은 계산을 자동으로 해줍니다:
- 투사체가 처음 발사될 때의 속도를 보고 각도를 정합니다.
- 공중에 날아가는 동안 중력 때문에 아래로 꺾이면, 그 곡선에 맞춰 모델의 각도(Pitch)도 아래로 서서히 기울어집니다.
핵심 메서드:
마법사가 투사체를 쏠 때 사용하는 shoot 메서드가 이 방향을 결정합니다.
// dx, dy, dz는 목표물과의 거리 차이
// velocity는 날아가는 속도, inaccuracy는 정확도(0이면 백발백중)
magicMissile.shoot(dx, dy, dz, 1.6F, 0.0F);
3. 렌더러(Renderer)에서의 처리 (가장 중요)
모델을 실제로 화면에 그릴 때, 엔티티가 가지고 있는 Yaw와 Pitch 값을 사용하여 모델을 회전시킵니다.
WizardEntityRenderer를 만들었던 것처럼 투사체용 렌더러를 만들 때, 아래와 같이 회전 로직이 들어갑니다. (보통 화살 렌더러 코드를 참고합니다.)
@Override
public void render(T entity, float entityYaw, float partialTicks, PoseStack poseStack, MultiBufferSource buffer, int packedLight) {
poseStack.pushPose();
// 1. 엔티티의 Yaw(좌우) 회전 적용
poseStack.mulPose(Vector3f.YP.rotationDegrees(Mth.lerp(partialTicks, entity.yRotO, entity.getYRot()) - 90.0F));
// 2. 엔티티의 Pitch(상하) 회전 적용 (날아가는 곡선에 맞춰 기울어짐)
poseStack.mulPose(Vector3f.ZP.rotationDegrees(Mth.lerp(partialTicks, entity.xRotO, entity.getXRot())));
// 3. 모델 그리기
this.model.renderToBuffer(poseStack, ...);
poseStack.popPose();
super.render(entity, entityYaw, partialTicks, poseStack, buffer, packedLight);
}
(참고: 90.0F 같은 보정치나 축 방향은 블록벤치에서 만든 방향에 따라 조금씩 다를 수 있지만, 기본적으로 이 로직이 방향을 결정합니다.)

- Modded Entity를 클릭하세요.
[ ]
엥 왜 다르지
z축위치

1. 색상으로 축 구별하기
이미지 중앙의 화살표와 오른쪽 하단 구석의 동그란 가이드를 보시면 됩니다.
- 빨간색 (Red): X축 (좌/우)
- 초록색 (Green): Y축 (위/아래)
- 파란색 (Blue): Z축 (앞/뒤)
2. 어디가 앞쪽(-Z)인가요?
마인크래프트에서 모든 엔티티(몹, 화살 등)의 앞방향은 북쪽, 즉 '마이너스 Z(-Z)' 방향입니다.
- 이미지에서 파란색 화살표가 가리키고 있는 방향은 **플러스 Z(+Z, 남쪽)**입니다.
- 그 반대 방향, 즉 **파란색 선을 따라 화면 왼쪽 위(멀어지는 방향)**로 가는 쪽이 **마이너스 Z(-Z, 북쪽)**이며, 이곳이 **투사체의 '앞부분'**입니다.
3. 현재 모델의 방향은?
지금 만드신 모델은 중심을 기준으로 대칭인 '구체' 형태라서 어느 쪽이 앞이어도 크게 상관은 없지만, 만약 이 마법탄에 **'앞부분 뾰족한 장식'**이나 '눈' 같은 걸 만드신다면 **파란색 화살표의 반대 방향(화면 왼쪽 상단 방향)**을 향하게 만드셔야 합니다.
[ ]투사체 속도 느리게 날아가도록
[ ]혹은 유도탄 형식도 가능한가
현재 작성된 코드에서 레벨업 시 강화되는 항목들은 다음과 같습니다:
- 최대 체력: 레벨당 **2.0(하트 1칸)**씩 증가 (WizardEvents.updateWizardMaxHealth)
- 공격 데미지: 레벨당 0.5씩 증가 (WizardSkirmishGoal.performSingleShot)
- 연사 횟수: 5레벨당 화살 1발 추가 (WizardSkirmishGoal.tick)
- 공격 쿨타임: 레벨당 **1틱(0.05초)**씩 감소 (최소 10틱 제한) (WizardSkirmishGoal.tick)
- 관통 능력: 5레벨당 관통 횟수 1 증가 (WizardSkirmishGoal.performSingleShot)
- 속도 조절: projectile.shoot의 0.4F 수치를 더 낮추면(예: 0.2F) 더 느려지고, 높이면 빨라집니다.
- 중력: 만약 마법탄이 곡선을 그리며 떨어지는 걸 원하시면 projectile.setNoGravity(true); 줄을 삭제하시면 됩니다. 하지만 속도가 느리면 바로 발 앞에 떨어질 수 있습니다.
셜커 클래스 분석
1. 어디에서 적을 는가? (내부 클래스 분석)
제공하신 코드 하단에 보면 션커가 적을 찾는 핵심 로직을 담은 두 가지 내부 클래스가 있습니다.
- ShulkerNearestAttackGoal: 플레이어를 찾는 로직입니다.
- ShulkerDefenseAttackGoal: 자신을 공격한 다른 적(Enemy)을 찾는 로직입니다.
이 클래스들은 공통적으로 NearestAttackableTargetGoal을 상속받는데, 여기서 성능 최적화가 일어납니다.
2. 성능을 지키는 핵심: getTargetSearchArea
션커는 맵 전체를 뒤지지 않습니다.
- AABB (Axis Aligned Bounding Box): 션커는 자기 주변의 특정 '박스' 구역만 설정합니다.
- 공간 인덱싱: 마인크래프트 월드는 엔티티들을 섹션(Section, 16x16x16 구역) 단위로 관리합니다. 션커가 적을 찾을 때는 이 박스 범위가 걸쳐 있는 섹션에 들어있는 엔티티 리스트만 가져옵니다. 맵 전체에 1000마리의 몹이 있어도, 션커 주변 섹션에 2마리만 있다면 그 2마리만 검사하므로 성능 부하가 매우 적습니다.
4. 션커만의 특수한 공격 메커니즘 (ShulkerAttackGoal)
션커는 적을 찾은 뒤에도 성능을 아끼기 위해 다음 단계를 거칩니다.
public void tick() {
// ... 생략 ...
double d0 = Shulker.this.distanceToSqr(livingentity);
if (d0 < 400.0D) { // 20블록 거리 안쪽인지 먼저 확인 (단순 거리 계산은 매우 빠름)
if (this.attackTime <= 0) {
this.attackTime = 20 + Shulker.this.random.nextInt(10) * 20 / 2;
// 탄환(ShulkerBullet) 생성
Shulker.this.level.addFreshEntity(new ShulkerBullet(...));
}
}
// ...
}
- 거리 기반 필터링: 타겟이 정해지면 복잡한 탐색 대신 distanceToSqr(제곱근 계산도 안 하는 단순 거리 비교)을 사용하여 사거리 안에 있는지만 체크합니다.
- 공격 쿨타임: attackTime 변수를 통해 매 틱 탄을 쏘는 게 아니라 일정 주기(약 1~3초)마다 로직을 수행합니다.
[ ]이게 뭔소리지
1. 전수 조사를 피하는 "섹션(Section)" 시스템
EntityGetter.getEntitiesOfClass 메서드가 호출될 때, 마인크래프트는 월드 전체 몹 리스트를 훑지 않습니다.
- 구조: 마인크래프트 월드는 16x16x16 크기의 '섹션(Section)' 단위로 쪼개져 있습니다.
- 엔티티 관리: 각 섹션은 자기 구역에 발을 들이고 있는 엔티티들의 리스트(EntitySectionStorage)를 별도로 가지고 있습니다.
- 탐색 방식: 션커가 getTargetSearchArea()로 만든 AABB 박스가 3개 섹션에 걸쳐 있다면, 엔진은 그 3개 섹션에 등록된 몹 리스트만 가져옵니다.
2. 시간 복잡도를 낮추는 "실행 주기 제어" (randomInterval)
보내주신 NearestAttackableTargetGoal 클래스의 첫 부분에 아주 중요한 코드가 있습니다.
public boolean canUse() {
if (this.randomInterval > 0 && this.mob.getRandom().nextInt(this.randomInterval) != 0) {
return false;
} else {
this.findTarget();
return this.target != null;
}
}
- 의미: randomInterval은 기본값이 10입니다. 즉, 이 션커는 매 틱(0.05초)마다 적을 찾는 게 아니라, 확률적으로 약 10틱(0.5초)에 한 번꼴로만 findTarget()(비싼 연산)을 실행합니다.
- 효과: 모든 몹이 동시에 계산을 하지 않도록 부하를 분산(Jittering)시키는 역할을 합니다.
3. 가장 비싼 연산(레이트레이싱)의 후순위 배치
TargetingConditions.test() 코드를 보시면 연산의 순서가 매우 전략적입니다.
// 1. 단순 거리 계산 (피타고라스 정리, 매우 빠름)
double d2 = p_26886_.distanceToSqr(p_26887_.getX(), p_26887_.getY(), p_26887_.getZ());
if (d2 > d1 * d1) {
return false;
}
// 2. 가장 비싼 '시야 가림(Line of Sight)' 체크 (레이트레이싱, 무거움)
if (this.checkLineOfSight && p_26886_ instanceof Mob) {
Mob mob = (Mob)p_26886_;
if (!mob.getSensing().hasLineOfSight(p_26887_)) { // <-- 이게 진짜 무거운 놈
return false;
}
}
- 최적화: '벽에 가려졌는지' 확인하는 작업은 주변 블록 데이터를 다 읽어야 해서 무겁습니다. 그래서 마인크래프트는 단순 거리 계산을 먼저 해서 사거리 밖이면 아예 시야 체크를 하지 않고 바로 return false 해버립니다.
아주 좋은 질문입니다! 질문자님이 혼동하시는 부분은 "공간 그 자체(블록)를 탐색하느냐" 아니면 **"엔티티 목록을 관리하느냐"**의 차이입니다.
결론부터 말씀드리면, 마인크래프트 서버는 모든 몹의 위치를 실시간으로 '목록' 형태로 정확히 알고 있습니다. 16x16x16 공간의 블록을 하나하나 뒤질 필요가 전혀 없습니다.
이해를 돕기 위해 "도서관" 비유를 들어 설명해 드릴게요.
1. 질문자님이 생각하신 방식 (블록 전수 조사)
도서관에 "홍길동"이라는 사람이 있는지 찾기 위해, 도서관의 모든 의자, 책상 밑, 화장실 칸을 하나하나 열어보는 것과 같습니다.
- 의자가 4,096개(16x16x16)라면, 사람이 1명뿐이어도 4,096번 확인해야 합니다.
- 이 방식은 매우 비효율적이고 마크에서도 이렇게 하지 않습니다.
2. 마인크래프트가 실제로 하는 방식 (엔티티 리스트 관리)
도서관의 각 구역(Section)마다 **"현재 이 구역에 있는 사람 명단"**이 적힌 게시판이 있는 것과 같습니다.
- 등록(Registration) 시스템: 몹(엔티티)이 움직일 때마다 서버에게 보고합니다. "나 지금 (10, 65, 12) 위치로 이동했어!"
- 섹션 업데이트: 서버는 몹이 움직이다가 16x16x16 경계를 넘어가면, 이전 섹션 명단에서 이름을 지우고, 새 섹션 명단에 이름을 추가합니다.
- 이 작업은 몹이 움직일 때만(Move 이벤트) 발생하므로 아주 가볍습니다.
- 탐색 시: 션커가 "내 주변 섹션에 누구 있어?"라고 물으면, 서버는 해당 섹션의 **명단(List)**만 딱 보여줍니다.
- 그 명단에 2명이 적혀 있다면, 서버는 단 2번의 루프만 돌면 됩니다. 4,096개 블록 위치는 쳐다보지도 않습니다.
3. 코드로 보는 원리 (추상적 구조)
마인크래프트 내부의 EntitySectionStorage는 대략 이런 구조로 데이터를 들고 있습니다.
// 핵심: 좌표(SectionPos)를 키로 하고, 그 안에 있는 엔티티들의 '리스트'를 값으로 가짐
Map<SectionPos, List<Entity>> entitiesBySection;
public List<Entity> getEntitiesInBox(AABB searchBox) {
List<Entity> result = new ArrayList<>();
// 1. 박스가 걸쳐 있는 섹션 좌표들을 계산 (단순 나눗셈이라 매우 빠름)
for (SectionPos pos : getOverlappingSections(searchBox)) {
// 2. 해당 섹션의 "명단"을 가져옴
List<Entity> sectionList = entitiesBySection.get(pos);
// 3. 명단에 있는 몹들만 꺼내서 실제로 박스 안에 있는지 최종 확인
for (Entity e : sectionList) {
if (searchBox.contains(e.position())) {
result.add(e);
}
}
}
return result;
}
4. 왜 성능이 좋은가?
- 블록 탐색 X: 16x16x16 = 4,096개 위치를 검사하는 게 아니라, 그 섹션에 몹이 3마리 살고 있다면 딱 3번만 검사합니다.
- 인덱싱: 서버는 몹이 어디 있는지 '탐색'해서 알아내는 게 아니라, 몹이 자기 위치를 바꿀 때마다 장부에 기록해두기 때문에, 찾을 때는 장부만 열면 됩니다.
요약하자면:
마크 서버는 몹의 위치 정보를 "블록 데이터(지형)"와 완전히 분리해서 "엔티티 전용 장부(Section List)"로 관리합니다. 그래서 적이 근처에 있는지 찾을 때 지형 블록을 뒤지는 일은 절대로 일어나지 않으며, 오직 그 구역에 살고 있다고 등록된 몹들의 명단만 확인하기 때문에 o(k)
(주변 몹 수)의 속도가 나오는 것입니다.
이런 방식을 Spatial Hashing(공간 해싱) 또는 Grid-based Spatial Partitioning이라고 부르며, 현대의 거의 모든 오픈월드 게임 엔진이 성능을 위해 사용하는 핵심 기법입니다.
보내주신 EntitySectionStorage 클래스는 제가 앞선 답변에서 설명해 드린 '엔티티 전용 장부(명단)'를 실제로 구현한 핵심 클래스입니다.
이 코드를 분석해 보면 마인크래프트가 어떻게 수만 개의 블록 탐색을 피하고 o(k)
(주변 엔티티 수)의 성능을 내는지 명확하게 알 수 있습니다.
1. 데이터를 저장하는 방식: Long2ObjectMap
private final Long2ObjectMap<EntitySection<T>> sections = new Long2ObjectOpenHashMap<>();
private final LongSortedSet sectionIds = new LongAVLTreeSet();
- sections: 섹션의 좌표(x, y, z)를 하나의 long 값으로 압축(Packing)하여 Key로 사용하고, 그 섹션에 포함된 엔티티 리스트인 EntitySection을 Value로 저장합니다.
- 특징: 3차원 배열(Entity[x][y][z])을 만들면 빈 공간이 너무 많아 메모리가 낭비되지만, 여기서는 엔티티가 존재하는 섹션만 Map에 담습니다. 즉, 텅 빈 공간은 연산 대상에서 아예 제외됩니다.
2. 탐색의 핵심 로직: forEachAccessibleNonEmptySection
이 메서드는 션커가 "내 주변에 누가 있지?"라고 물을 때 호출되는 가장 중요한 로직입니다.
public void forEachAccessibleNonEmptySection(AABB p_188363_, Consumer<EntitySection<T>> p_188364_) {
// 1. AABB(탐색 범위)를 섹션 좌표로 변환 (블록 좌표를 16으로 나눔)
int i = SectionPos.posToSectionCoord(p_188363_.minX - 2.0D);
int j = SectionPos.posToSectionCoord(p_188363_.minY - 2.0D);
// ... l, i1, j1 (최대 좌표들)
// 2. 해당 범위 내의 섹션들만 루프를 돔
for(int k1 = i; k1 <= l; ++k1) {
// ... (중략) ...
// 3. 실제 존재하는 섹션 ID들 중에서 해당 범위에 속하는 것만 필터링
LongIterator longiterator = this.sectionIds.subSet(l1, i2 + 1L).iterator();
while(longiterator.hasNext()) {
long j2 = longiterator.nextLong();
// ... Y, Z 좌표 체크 ...
EntitySection<T> entitysection = this.sections.get(j2);
// 4. 섹션이 비어있지 않고(Non-Empty), 접근 가능할 때만 처리!
if (entitysection != null && !entitysection.isEmpty() && entitysection.getStatus().isAccessible()) {
p_188364_.accept(entitysection);
}
}
}
}
- 분석:
- 질문자님이 걱정하셨던 "공간 탐색"은 여기서 섹션 단위로 일어납니다.
- 블록 4,096개를 뒤지는 게 아니라, AABB가 걸쳐 있는 **섹션 몇 개(보통 1~8개 사이)**만 가져옵니다.
- 심지어 !entitysection.isEmpty() 체크를 통해, 엔티티가 한 마리도 없는 구역은 바로 패스합니다.
3. 최종 엔티티 획득: getEntities
public void getEntities(AABB p_156891_, Consumer<T> p_156892_) {
this.forEachAccessibleNonEmptySection(p_156891_, (p_188368_) -> {
p_188368_.getEntities(p_156891_, p_156892_);
});
}
- 먼저 forEachAccessibleNonEmptySection을 통해 엔티티가 있을 법한 섹션들만 골라냅니다.
- 골라낸 각 섹션(EntitySection)에게 "너희 명단 중에서 이 AABB 박스 안에 진짜로 들어있는 애들만 나한테 줘"라고 시킵니다.
- EntitySection 내부에서는 이미 List 형태로 엔티티들을 들고 있기 때문에, 블록을 뒤질 필요 없이 리스트를 순회하며 위치만 대조하면 됩니다.
결론: 왜 성능이 좋은가? (질문자님의 의문 해결)
- 공간의 추상화: 서버는 월드를 '블록의 집합'이 아니라 **'섹션이라는 바구니들의 집합'**으로 봅니다.
- sparse(희소) 데이터 처리: 몹이 없는 구역은 sections Map에 아예 존재하지 않거나 isEmpty로 걸러집니다. 서버 전체에 몹이 100마리 있다면, 그 100마리가 들어있는 소수의 섹션 바구니만 열어보면 됩니다.
- 위치 업데이트 방식: 엔티티가 움직일 때마다 remove와 getOrCreateSection을 통해 바구니를 옮겨 담습니다. 이 덕분에 탐색 시점에는 **"이 바구니엔 무조건 몹이 있다"**는 확신을 가지고 장부만 열면 되는 것입니다.
요약하자면:
션커가 적을 찾을 때 블록을 뒤지지 않는 이유는, 이 EntitySectionStorage라는 **'엔티티 전용 위치 장부'**가 섹션 단위로 이미 잘 정리되어 있어서, 대상이 있을 법한 몇 개의 장부 페이지만 읽으면 되기 때문입니다. 인접 행렬처럼 모든 칸을 뒤지는 것과는 완전히 다른, 매우 효율적인 구조입니다.
네, 정확한 판단입니다! 유도탄(Homing Projectile)을 만들 때 가장 효율적인 방법은 마인크래프트 엔진이 이미 관리하고 있는 섹션 장부(EntitySectionStorage)를 활용하는 것입니다.
이론적으로 어떻게 유도탄의 타겟팅 시스템을 설계해야 성능과 정확도를 모두 잡을 수 있는지 설명해 드릴게요.
1. 타겟 획득 단계: "바구니(Section) 털기"
유도탄을 발사하는 순간, 또는 유도탄이 공중에서 적을 새로 탐색해야 할 때만 EntitySectionStorage를 이용합니다.
- AABB 설정: 유도탄을 중심으로 유도 거리(예: 반경 16블록)만큼의 박스를 설정합니다.
- API 호출: 직접 Map을 뒤지는 대신, 엔진이 제공하는 level.getEntitiesOfClass() 또는 level.getNearestEntity()를 사용합니다.
- 이 메서드들이 내부적으로 우리가 분석한 EntitySectionStorage.getEntities()를 호출합니다.
- 이 과정에서 엔진은 유도탄이 위치한 섹션과 인접 섹션의 엔티티 명단만 빠르게 훑어서 후보군을 반환합니다.
2. 타겟 고정(Lock-on) 단계: "참조 저장"
유도탄은 매 틱(Tick)마다 "주변에 누가 있지?"라고 새로 물어볼 필요가 없습니다.
- Entity Reference 저장: 한 번 적을 찾았다면, 그 엔티티의 인스턴스(혹은 UUID)를 유도탄 클래스 내부에 변수로 저장합니다. (private Entity target;)
- 실시간 위치 추적: 일단 타겟이 정해지면, 다음 틱부터는 target.getX(), target.getY() 등을 호출하기만 하면 됩니다.
- 엔티티 객체는 자기 좌표를 이미 필드로 들고 있기 때문에, 이때는 섹션 탐색조차 필요 없는 연산이 됩니다.
- O(1)
3. 유도 로직의 최적화 (성능과 직결)
유도탄의 궤적을 계산할 때 모든 연산을 매 틱 수행하면 부담이 될 수 있습니다. 이론적으로 권장되는 구조는 다음과 같습니다.
- 탐색 주기 조절:
- 만약 타겟이 없다면 매 틱 탐색하지 말고, 5~10틱에 한 번씩만 섹션 탐색(findTarget)을 수행합니다. (우리가 분석한 randomInterval과 같은 원리)
- 가시성(Line of Sight) 체크:
- 섹션에서 몹을 찾았더라도, 벽 뒤에 있는 적을 쫓아가면 안 됩니다.
- 하지만 시야 체크(레이트레이싱)는 매우 무겁습니다.
- 전략: 타겟과의 직선거리를 먼저 계산하고, 거리가 가까울 때만 level.clip()(레이트레이싱)을 실행하여 최종 타겟으로 확정합니다.
4. 이론적 흐름도 (Pseudo logic)
// 유도탄의 tick() 메서드 내부
public void tick() {
if (this.target == null || !this.target.isAlive()) {
// 1. 타겟이 없으면 섹션 장부를 뒤져서 가장 가까운 적을 찾음 (비정기적 실행)
if (this.tickCount % 5 == 0) {
this.target = this.level.getNearestEntity(
this.level.getEntitiesOfClass(LivingEntity.class, this.getBoundingBox().inflate(16.0)),
TargetingConditions.forCombat(), this, ...
);
}
} else {
// 2. 타겟이 있으면 장부를 뒤지지 않고, 고정된 타겟의 좌표로 벡터 계산만 수행
Vec3 targetPos = this.target.position();
Vec3 bulletPos = this.position();
// 유도 연산 (현재 속도 벡터를 타겟 방향으로 조금씩 수정)
Vec3 direction = targetPos.subtract(bulletPos).normalize();
this.setDeltaMovement(this.getDeltaMovement().lerp(direction, 0.1));
}
}
요약: 유도탄 제작 시 명심할 점
- 장부(Section Map)는 "타겟을 처음 잡을 때만" 사용한다.
- 엔진의 EntitySectionStorage 덕분에 월드 전체를 뒤지지 않아도 되므로, 발사 시 발생하는 렉이 최소화됩니다.
- 한 번 잡은 타겟은 객체 자체를 기억한다.
- 이후 위치 추적은 장부 탐색 없이 메모리상의 좌표 필드만 읽으면 되므로 매우 빠릅니다.
- 물리 연산과 탐색 로직을 분리한다.
- 섹션 탐색은 가끔, 벡터 이동 계산은 매 틱 수행하는 것이 서버 성능 유지의 핵심입니다.
이 이론을 바탕으로 구현하시면, 마인크래프트 내부 션커의 메커니즘을 완벽하게 응용하면서도 성능상으로 매우 최적화된 유도탄 무기를 만드실 수 있습니다!
맞습니다. 타겟의 위치는 매 틱(0.05초)마다 계속 변합니다. 하지만 여기서 **"객체(Object)를 기억한다"**는 개념이 프로그래밍(자바)에서 어떻게 작동하는지를 이해하면 의문이 풀리실 겁니다.
결론부터 말씀드리면, 우리는 타겟의 '좌표'를 저장하는 게 아니라, 타겟 '자체(메모리 주소)'를 가리키고 있기 때문에 타겟이 아무리 움직여도 실시간으로 추적이 가능합니다.
1. '사진'이 아니라 '추적기'를 붙이는 것
프로그래밍에서 객체를 변수에 저장하는 것은 두 가지 차이가 있습니다.
- 잘못된 방식 (좌표 저장): "적의 현재 위치(예: X:10, Y:60)를 적어둔다."
- 이러면 적이 움직였을 때 내가 가진 정보는 과거의 기록이 되어버립니다. 다시 장부를 뒤져야 하죠.
- 옳은 방식 (객체 참조): "적이라는 **존재(Entity 객체)**를 내 변수에 연결한다."
- 이것은 마치 적의 몸에 GPS 추적기를 붙여놓은 것과 같습니다.
2. 왜 실시간 추적이 가능한가? (메모리 구조)
마인크래프트 서버의 모든 엔티티는 자기 자신의 좌표 정보를 메모리 상의 특정 필드(double x, y, z)에 들고 있습니다.
- 엔티티의 행동: 매 틱마다 적(몹)은 AI나 물리 엔진에 의해 자기 좌표를 업데이트합니다. (this.x += motionX; 등)
- 유도탄의 행동: 유도탄은 자기가 들고 있는 target 변수를 통해 그 엔티티의 메모리 주소로 바로 점프합니다.
- 결과: 유도탄이 target.getX()를 호출하는 순간, 방금 막 업데이트된 따끈따끈한 실시간 좌표를 즉시 읽어오게 됩니다.
이 과정에는 '탐색(Search)' 과정이 전혀 없습니다. 이미 알고 있는 주소로 가서 값만 읽어오는 것이기 때문에 연산 속도가 o(1)
, 즉 거의 0에 가깝습니다.
3. 비교: 장부 탐색 vs 객체 참조
| 구분 | 장부 탐색 (Section Storage) | 객체 참조 (Reference) |
| 하는 일 | "이 근처 박스(AABB) 안에 누가 있는지 명단 다 가져와!" | "내가 찍어둔 놈, 지금 좌표가 뭐야?" |
| 비용 | 섹션 계산 + 리스트 순회 + 거리 대조 (무거움) | 메모리 값 직접 읽기 (매우 가볍움) |
| 용도 | 처음 타겟을 정할 때 | 타겟을 정한 후 쫓아갈 때 |
4. 주의해야 할 점 (유도탄 로직의 디테일)
타겟이 움직이는 것을 추적하는 건 매우 빠르지만, 유도탄 코드를 짤 때 반드시 체크해야 하는 **'상태 변화'**들이 있습니다.
- 타겟이 죽었는가? (!target.isAlive()): 죽은 타겟을 계속 쫓아가면 에러가 나거나 허공을 맴돕니다. 이때는 다시 장부를 뒤져서 새 타겟을 찾아야 합니다.
- 타겟이 너무 멀어졌는가?: 유도 사거리를 벗어났는지 거리 계산(distanceToSqr)을 한 번씩 해줘야 합니다.
- 타겟이 보이지 않는가? (시야 차폐): 적이 벽 뒤로 숨었을 때도 계속 쫓아갈 것인지 결정해야 합니다. (이때만 레이트레이싱 hasLineOfSight를 사용)
요약하자면
"타겟이 움직이면 어떡하나?"라는 걱정은 하실 필요가 없습니다. 자바의 참조(Reference) 기능 덕분에, 유도탄은 타겟의 **'이름표'**를 꽉 잡고 있는 셈이고, 그 이름표를 통해 언제든지 그 적의 실시간 위치를 0.000001초 만에 확인할 수 있기 때문입니다.
그래서 **"처음 잡을 때만 섹션(장부)을 뒤지고, 그 후엔 잡은 놈만 팬다"**는 전략이 가장 효율적인 것입니다.
코드 없이, 현재 상속 구조와 **'의존성'**에 대해 아주 쉽게 설명해 드릴게요.
1. 무엇을 상속받았나요?
지금 WizardEntityV3는 WizardEntity(법사2)를 상속받은 것이 아니라, **AbstractAllyEntity (최상위 부모)**를 상속받았습니다.
- 부모: AbstractAllyEntity (레벨링, HUD, NBT 저장 등 공통 엔진)
- 첫째 아들: WizardEntity (Ver 2: 레벨업 시 강해지는 법사)
- 둘째 아들: WizardEntityV3 (Ver 3: 레벨업만 하고 능력치는 고정된 실험용 법사)
즉, Ver 3는 Ver 2를 상속받은 게 아니라 Ver 2와 '형제' 관계입니다.
2. 법사 2의 기능에 의존하나요?
아니요, 의존하지 않습니다.
만약 법사 2(WizardEntity)를 상속받았다면 법사 2의 코드가 바뀌었을 때 법사 3도 영향을 받았겠지만, 지금은 **뿌리(AbstractAllyEntity)**만 같을 뿐입니다.
- 독립성: 법사 2의 공격 방식을 아무리 수정해도 법사 3는 아무런 영향을 받지 않습니다.
- 공통점: 오직 "레벨이 있고, 머리 위에 HUD가 뜨고, 저장이 된다"라는 기본 규칙만 공유합니다.
3. 오버라이딩(Overriding)의 역할
질문하신 대로 오버라이딩 덕분에 의존성 걱정이 사라집니다.
- 부모(AbstractAllyEntity)가 "자식들아, 너희는 레벨업할 때 보너스 체력을 얼마나 가질 거니?"라고 물어봅니다.
- 법사 2는 "저는 2.0만큼 주세요!"라고 대답(오버라이딩)합니다.
- 법사 3는 "저는 0 주시고 아무것도 하지 마세요!"라고 대답(오버라이딩)합니다.
이렇게 각자 대답을 다르게 하기 때문에, 부모의 기능을 가져오면서도 내용은 완전히 다르게 채울 수 있는 것입니다.
4. 왜 이 방식이 좋은가요? (요약)
지금 구조는 **"엔진은 공유하되 껍데기와 성능은 따로 노는 것"**과 같습니다.
- 엔진 (AbstractAllyEntity): "레벨이 오르면 패킷을 보내고 데이터를 저장한다"는 복잡한 로직. (이건 다시 짜기 귀찮으니 상속받음)
- 커스텀 (WizardEntityV3): "나는 3발 쏠 거야", "나는 안 강해질 거야"라는 개성. (이건 오버라이딩으로 새로 정함)
결론적으로, 법사 2의 기능에는 전혀 의존하지 않으면서도, 레벨업 시스템이라는 거대한 기계 장치만 공짜로 빌려 쓰고 있는 아주 효율적인 상태입니다. 걱정하지 않으셔도 됩니다!
"속성 등록"이란 엔티티가 소환될 때 필요한 기본적인 능력치(기본 체력, 이동 속도, 공격 데미지 등)를 게임 엔진에 알려주는 과정을 말합니다. 이 코드가 없으면 스폰 알로 마법사를 소환하려고 할 때 게임이 튕기게 됩니다.
1. 이 법사는 누구의 자손인가요? (상속 계보)
법사 3은 **AbstractAllyEntity**의 직접적인 자손입니다.
- 최상위 부모: AbstractVillager (마인크래프트 기본 주민)
- 중간 부모 (우리 모드 엔진): AbstractAllyEntity
- 여기에는 레벨링 시스템, HUD 표시, 데이터 저장, 그리고 데미지 처리(hurt) 로직이 들어있습니다.
- 현재 자식: WizardEntityV3
- 부모가 만들어둔 "기계(엔진)"를 그대로 물려받고, 본인은 "공격 방식(3발, 느린 속도)"과 "레벨업 보너스 없음"이라는 설정만 추가한 상태입니다.
2. 관리자 검에 의한 즉사 원리 (hurt 메서드)
네, 관리자 검을 들고 때리면 즉시 사망합니다. 그 이유는 부모 클래스인 AbstractAllyEntity에서 데미지를 받는 규칙(hurt 메서드)을 이미 그렇게 정해두었기 때문입니다.
작동 순서:
- 공격 발생: 플레이어가 관리자 검으로 법사 3을 때립니다.
- 부모의 규칙 확인: 법사 3은 자기가 직접 "데미지 받기 규칙(hurt)"을 만들지 않았으므로, 부모인 **AbstractAllyEntity**의 규칙을 따릅니다.
- 관리자 검 검사: 부모의 규칙(hurt 메서드) 안에는 **"나를 때린 놈이 관리자 검을 들고 있는가?"**를 확인하는 코드가 있습니다.
- 강제 처형: 관리자 검임이 확인되면, 법사의 남은 체력이 얼마든 상관없이 데미지를 **무한대(Float.MAX_VALUE)**로 뻥튀기해서 적용합니다.
걍 kill()메소드 호출하는거 아니엇나 [ ]
- 사망: 결국 법사 3은 한 방에 죽게 됩니다.
3. 요약하자면
- 상속 관계: 법사 3은 **AbstractAllyEntity**라는 강력한 부모 밑에서 태어났습니다.
- 즉사 여부: 부모가 **"관리자 검에는 무조건 죽어라"**라는 가문의 법도를 세워놨기 때문에, 법사 3도 그 법도를 그대로 이어받아 즉사하게 됩니다.
- 의미: 법사 3의 코드를 짤 때 즉사 로직을 따로 적지 않아도 되는 이유는, 부모 클래스에 이미 그 기능이 들어있고 그것을 상속받았기 때문입니다.
이것이 바로 **상속(Inheritance)**의 힘입니다! 부모에게 공통적인 규칙(관리자 검 즉사, 레벨링 등)을 한 번만 잘 적어두면, 앞으로 만들 법사 4, 법사 5도 자동으로 관리자 검에 죽게 됩니다.
로그를 보니 법사2(wizard_ver2)의 속성(Attribute) 등록이 두 번 중복되어 발생한 오류입니다.
오류 원인 분석
에러 메시지: java.lang.IllegalStateException: Duplicate DefaultAttributes entry: entity.mymod.wizard_ver2
로그의 리스너 목록을 보면 다음 두 곳에서 동시에 등록을 시도하고 있습니다:
- 리스너 16: changmin.myMod.feature.wizardVer2.WizardEvents.onEntityAttributeCreation
- 리스너 18: changmin.myMod.MyMod$ModEventBusEvents.onAttributeCreate (여기가 제가 방금 추가하라고 말씀드린 부분입니다.)
즉, 개별 파일(WizardEvents)에도 등록 코드가 있고, 메인 클래스(MyMod)에도 코드를 넣어서 충돌이 발생한 것입니다.

투사체가 남아잇는문제
첫번째 투사체만 타격되는건가
투사체가 사라지지 않고 남아있던 문제와, 여러 발 중 첫 발만 제대로 맞는 것처럼 보였던 현상이 이론적으로 어떻게 해결되었는지 핵심 원리 2가지를 설명해 드릴게요.
1. 투사체가 사라지지 않고 박혀있던 이유 (화살의 본성)
원인: 우리가 만든 마법탄은 마인크래프트의 AbstractArrow (화살) 클래스를 상속받았습니다.
- 화살의 기본 설계: 화살은 무언가에 부딪히면 사라지는 게 아니라, 블록에 박히거나 엔티티의 몸에 박혀서 일정 시간 동안 **'아이템 상태'**로 유지되도록 설계되어 있습니다.
- 문제 상황: 마법탄 모델을 썼는데 화살의 로직이 그대로 작동하니, 좀비 몸에 마법탄 모델이 화살처럼 박힌 채로 렌더링되고 있었던 것입니다.
해결 원리 (확실한 소멸 명령):
- 수정된 코드에서는 onHit (충돌 시) 메서드를 오버라이딩(재정의)했습니다.
- 부모(화살)는 "부딪히면 박혀라"라고 했지만, 우리는 그 명령을 가로채서 **this.discard() (이 엔티티를 세계에서 즉시 제거해라)**라는 명령을 내렸습니다.
- 이로 인해 마법탄이 좀비나 땅에 닿는 순간, 화살처럼 박히는 단계로 넘어가기 전에 게임 세계에서 아예 삭제되도록 바뀌어 문제가 해결된 것입니다.
2. 첫 타만 사라지고 나머지는 남아있던 이유 (무적 시간과 판정)
원인: 좀비의 **'무적 시간(I-frames)'**과 '충돌 로직의 위치' 때문입니다.
- 무적 시간: 마인크래프트의 몹은 데미지를 입으면 몸이 빨갛게 변하며 약 0.5초(10틱) 동안 다음 데미지를 무시합니다.
- 기존 로직의 실수: 만약 코드가 "데미지를 입혔을 때만 사라져라"라고 되어 있었다면, 3발 중 첫 발은 좀비에게 데미지를 주고 사라지지만, 뒤따라오던 2, 3회차 마법탄은 좀비의 무적 시간에 막혀 '데미지를 주지 못했기 때문에' 사라지지 않고 그 자리에 멈추거나 박히게 됩니다.
해결 원리 (결과에 상관없는 소멸):
- 수정된 코드에서는 discard() 명령을 **"데미지를 입혔을 때"가 아니라 "충돌했을 때(onHit)"**라는 더 넓은 범위에 넣었습니다.
- 이제 마법탄은 좀비가 무적 상태라서 데미지를 입든 안 입든, **"일단 좀비의 히트박스에 닿았다면 내 할 일은 끝났다"**고 판단하고 즉시 사라지게 됩니다.
- 그래서 3발 모두 좀비에게 닿자마자 깔끔하게 사라지는 모습을 보이게 된 것입니다.
3. (추가) 0.2의 느린 속도에서도 안 떨어지는 이유
원인: 중력(Gravity)
- 일반적인 화살은 속도가 0.2 정도로 느리면 발사하자마자 중력 때문에 발밑으로 툭 떨어집니다.
해결 원리:
- 코드에서 **setNoGravity(true)**를 설정했습니다. 이 명령은 마인크래프트의 물리 물리 엔진에서 "이 엔티티에는 중력을 적용하지 마라"라고 선언하는 것입니다.
- 덕분에 속도가 아무리 느려도 아래로 휘지 않고, 발사된 각도 그대로 우주 공간을 날아가듯 일직선으로 날아갈 수 있게 된 것입니다.
요약하자면:
- onHit에서 discard()를 강제함: 화살처럼 박히는 본능을 억제하고 즉시 소멸하게 함.
- 데미지 성공 여부와 무관하게 소멸: 무적 시간에 막힌 후속탄들도 닿기만 하면 사라지게 함.
- setNoGravity(true): 느린 속도에서도 땅으로 떨어지지 않게 함.
이 3가지 논리가 합쳐져서 지금의 깔끔한 마법탄 발사 모습이 완성된 것입니다!
이러면 무적 판정잇으면, 굳이 3발을 쏠필요가 없나
뭐 넉백이나, 빙결효과생각하거나, 적이 많을떄를 생각해보면 의미잇을지도
[ ok ]
5칸 안으로 오면 도망가고, 5~15칸 사이면 공격
1. 엔티티 참조 및 위치 추적 원리
- 참조 변수: 네, 맞습니다. AI 코드에서 LivingEntity target = this.wizard.getTarget();을 통해 가져오는 target은 메모리 상의 **객체 주소(참조)**입니다.
- 실시간 추적: 좀비가 이동하면 그 객체 내부의 x, y, z 좌표값이 실시간으로 변합니다. 우리는 target.getX()를 호출할 때마다 그 좀비의 "현재" 위치를 가져오게 되므로, 대상이 움직여도 끝까지 추적할 수 있는 것입니다.
일단작동은 하는데, 다이아좀비가 너무 방어력이 쎈듯,
관리자용 검이 왜 작동이 안하지
엔티티 수정 최소화: 각 법사나 좀비 클래스마다 hurt 메서드를 일일이 오버라이딩하면 코드가 지저분해집니다. AdminKillHandler처럼 이벤트 핸들러를 사용하면
투사체가 추가될 때마다 instanceof를 적는 것은 비효율적이고 유지보수가 힘든 **"나쁜 코드"**가 맞습니다.
또한, 법사 3(V3)만 별개로 작동하게 하고 싶으시다면, 글로벌 인터페이스(IVillagerAlly)에 의존하지 않고 패키지 내부에서 "끼리끼리" 보호하는 방식이 훨씬 깔끔합니다.
투사체 문제 해결: IMyModProjectile 인터페이스
투사체가 늘어날 때마다 instanceof를 쓰지 않으려면, 모든 마법탄이 공통으로 구현하는 인터페이스를 하나 만들면 됩니다.
IMyModProjectile.java (새로 생성)
주인 리턴
이러면 이 메소드 호출하면 주인이 리턴되니깐
package changmin.myMod.feature.inheritanceEntity;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.Projectile;
/**
* 모든 커스텀 투사체가 구현할 인터페이스.
* 이 인터페이스 덕분에 더 이상 마법탄 종류를 일일이 확인할 필요가 없습니다.
*/
public interface IMyModProjectile {
// 투사체를 쏜 주인(Owner)을 바로 가져오는 기본 메서드
default Entity getShooter() {
if (this instanceof Projectile p) {
return p.getOwner();
}
return null;
}
}
질문하신 **"투사체의 주인(Shooter)을 리턴"**하는 기능을 통해, 마법탄이 날아와서 누군가를 맞췄을 때 **"이 마법탄을 쏜 놈이 누구인가?"**를 확인하고, 만약 쏜 놈과 맞은 놈이 둘 다 법사 3이라면 데미지를 취소하는 방식입니다.
결국 isinstanceof는 사용하지만,
대신 이번에는 내부 메소드가 투사체의 주인을 리턴하니깐
주인이 누구인지 몹의 갯수만큼 쓸필요가 없다는거지,
대신 이게 법사3의것인지 아닌지의
isinstanceof는 필요하다는건가
오 ㅋ
좀하네
법사 3이 나중에 얼음 화살, 불꽃 화살, 번개 화살 등 10가지 새로운 투사체를 쏘게 되더라도, 데미지 핸들러 코드는 단 한 줄도 고칠 필요가 없습니다.
인터페이스(IZombieAlly)로도 타겟팅이 가능합니다.
자바에서 instanceof는 클래스뿐만 아니라 인터페이스에도 똑같이 작동하기 때문입니다. 다만, 마인크래프트의 기본 NearestAttackableTargetGoal은 생성자에서 Class 타입을 받으므로, **"모든 생명체(LivingEntity) 중에서 IZombieAlly 인터페이스를 가진 개체만 골라라"**라는 필터(Predicate) 조건을 추가해주면 됩니다
[ ]
선제공격하는지 안하는지 문제
일반좀비로 실험해볼까나
[ ]
현재 물속에 있는건 공격 못하는 문제

[ ]
zombie상속하기에
좀비들은 선제타격하나
근데 주민들도, 선제공격하는데
제공해주신 코드를 분석한 결과를 바탕으로, 질문하신 **"다이아 좀비가 어떻게 선제 타격(인식 및 공격)을 하는가"**에 대해 명확히 답변해 드리겠습니다.
1. Zombie 클래스를 상속받고 있는가?
아니오, 상속(Inheritance)받지 않았습니다.
보통 새로운 몹을 만들 때 class DiamondZombie extends Zombie와 같은 방식을 사용하지만, 현재 코드는 Forge의 Capability 시스템을 사용하고 있습니다.
- DiamondZombieEvents.java를 보면 new Zombie(EntityType.ZOMBIE, level)를 통해 기본 마인크래프트의 좀비 객체를 그대로 생성합니다.
- 대신, 그 좀비 객체에 DiamondZombieDataProvider를 통해 "이 좀비는 다이아 좀비다"라는 데이터(Capability)만 태그처럼 붙여준 것입니다.
2. 선제 타격(인식)은 어떻게 이루어지는가?
이 다이아 좀비는 내부적으로는 net.minecraft.world.entity.monster.Zombie 클래스의 인스턴스이므로, 마인크래프트 원본 좀비의 AI를 그대로 따릅니다.
좀비가 플레이어를 발견하고 공격을 시작하게 만드는 "선제 타격" 관련 핵심 클래스와 코드는 다음과 같습니다.
마인크래프트 원본 코드 내 핵심 클래스:
- NearestAttackableTargetGoal: 몹이 주변에서 공격 대상을 찾는 AI 로직입니다.
- Zombie 클래스의 registerGoals() 메소드: 여기서 좀비가 어떤 행동을 할지 결정합니다.
원본 좀비의 공격 인식 코드 (개념적 설명):
// net.minecraft.world.entity.monster.Zombie 클래스 내부 (원본)
protected void registerGoals() {
// 플레이어를 인식하고 타겟으로 잡는 AI 추가
this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true));
// 주민을 인식하고 타겟으로 잡는 AI 추가
this.targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, AbstractVillager.class, false));
}
- 인식 범위: 기본적으로 마인크래프트 좀비는 약 35~40블록 내의 플레이어를 감지합니다.
- 작동 방식: NearestAttackableTargetGoal이 매 틱마다 주변을 스캔하여 '공격 가능한 타겟(플레이어)'이 있는지 확인하고, 발견하면 즉시 추격 상태로 전환합니다.
3. 현재 Mod 코드에 "선제 타격" 관련 로직이 있나요?
현재 공유해주신 코드에는 AI(공격 인식 범위나 속도)를 수정하는 코드는 포함되어 있지 않습니다.
공유해주신 코드가 담당하는 역할은 다음과 같습니다:
- Data/Network: 좀비가 '일반'인지 '구속 공격형'인지 데이터를 저장하고 서버-클라이언트 간 동기화.
- Renderer: 좀비 머리 위에 이름표(Diamond Zombie)와 체력 바를 표시.
- Events: 좀비가 스폰될 때 다이아몬드 장비를 입혀주고 데이터를 부여.
4. 만약 다이아 좀비만 더 멀리서 공격하게 만들고 싶다면?
현재 코드 구조에서 다이아 좀비의 인식 범위(선제 타격 능력)를 강화하고 싶다면, DiamondZombieEvents 클래스에 EntityJoinWorldEvent를 추가하여 AI를 수정해야 합니다.
예시 코드 (개념):
@SubscribeEvent
public static void onEntityJoin(EntityJoinWorldEvent event) {
if (event.getEntity() instanceof Zombie zombie) {
zombie.getCapability(DiamondZombieDataProvider.DIAMOND_ZOMBIE_DATA).ifPresent(data -> {
if (data.isSpecial()) {
// 여기서 인식 범위를 늘리거나, 더 공격적인 Goal을 추가할 수 있습니다.
// 예: 인식 범위를 100블록으로 늘리는 속성 수정 등
}
});
}
}
요약
- 상속 여부: X (기본 좀비에 데이터만 붙임)
- 선제 타격 원리: 마인크래프트 원본 Zombie 클래스의 NearestAttackableTargetGoal AI에 의해 작동함.
- 코드 위치: 마인크래프트 원본 소스의 net.minecraft.world.entity.monster.Zombie와 net.minecraft.world.entity.ai.goal.target.NearestAttackableTargetGoal을 참고하시면 됩니다.
현재 다이아 좀비가 선제 타격을 하는 이유는 **"원본 좀비 자체가 플레이어를 보면 먼저 공격하도록 설계된 몹"**이기 때문이며, 이 모드는 그 좀비에게 장비와 이름표를 추가해준 상태입니다.
1. "누구를" 공격할 것인가? (addBehaviourGoals 메소드)
코드 중간의 addBehaviourGoals() 메소드를 보면 targetSelector에 목표를 찾는 AI들을 등록하고 있습니다. 여기서 좀비의 우선순위와 탐색 대상이 결정됩니다.
protected void addBehaviourGoals() {
// ... 생략 ...
// 1순위: 자신을 공격한 대상에게 복수 (주변 좀비 피글린도 호출 가능)
this.targetSelector.addGoal(1, (new HurtByTargetGoal(this)).setAlertOthers(ZombifiedPiglin.class));
// 2순위: 플레이어 탐색 (선제 타격의 핵심)
this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true));
// 3순위: 주민 탐색
this.targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, AbstractVillager.class, false));
// 3순위: 철 골렘 탐색
this.targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, IronGolem.class, true));
// 5순위: 거북이 탐색 (특정 조건 하에)
this.targetSelector.addGoal(5, new NearestAttackableTargetGoal<>(this, Turtle.class, 10, true, false, Turtle.BABY_ON_LAND_SELECTOR));
}
- NearestAttackableTargetGoal: 이 클래스가 실제로 주변을 스캔하여 가장 가까운 적을 찾는 역할을 합니다.
- 숫자(1, 2, 3...): 우선순위입니다. 숫자가 낮을수록 더 중요한 목표로 인식합니다.
2. "얼마나 멀리서" 탐색할 것인가? (FOLLOW_RANGE)
좀비가 플레이어를 감지하는 거리는 클래스 상단의 속성(Attribute) 설정 부분에서 결정됩니다.
public static AttributeSupplier.Builder createAttributes() {
return Monster.createMonsterAttributes()
.add(Attributes.FOLLOW_RANGE, 35.0D) // 기본 탐색 범위: 35블록
.add(Attributes.MOVEMENT_SPEED, (double)0.23F)
.add(Attributes.ATTACK_DAMAGE, 3.0D)
.add(Attributes.ARMOR, 2.0D)
.add(Attributes.SPAWN_REINFORCEMENTS_CHANCE);
}
- Attributes.FOLLOW_RANGE: 기본적으로 35블록 이내에 있는 타겟을 탐색합니다.
또한, handleAttributes 메소드에는 재미있는 로직이 있습니다:
protected void handleAttributes(float p_34340_) {
// ... 생략 ...
double d0 = this.random.nextDouble() * 1.5D * (double)p_34340_;
if (d0 > 1.0D) {
// 스폰될 때 운이 좋으면 탐색 범위(FOLLOW_RANGE)가 보너스로 더 늘어납니다.
this.getAttribute(Attributes.FOLLOW_RANGE).addPermanentModifier(new AttributeModifier("Random zombie-spawn bonus", d0, AttributeModifier.Operation.MULTIPLY_TOTAL));
}
}
즉, 어떤 좀비는 태생적으로 일반 좀비보다 훨씬 멀리서(35블록 이상) 플레이어를 발견하고 쫓아올 수 있습니다.
3. "어떻게" 추격하는가? (ZombieAttackGoal)
타겟을 찾았다면, 실제로 그 타겟에게 달려가는 로직은 goalSelector에 등록된 ZombieAttackGoal이 담당합니다.
this.goalSelector.addGoal(2, new ZombieAttackGoal(this, 1.0D, false));
- 타겟이 정해지면 이 AI가 활성화되어 플레이어의 위치로 경로(Path)를 계산하고 이동 속도를 조절하여 접근합니다.
4. 특별한 탐색: 거북이 알 (ZombieAttackTurtleEggGoal)
코드 맨 아래쪽에는 일반적인 생명체 외에도 거북이 알을 찾아가서 부수는 특수 탐색 로직이 포함되어 있습니다.
class ZombieAttackTurtleEggGoal extends RemoveBlockGoal {
ZombieAttackTurtleEggGoal(PathfinderMob p_34344_, double p_34345_, int p_34346_) {
super(Blocks.TURTLE_EGG, p_34344_, p_34345_, p_34346_);
}
// ... 알을 찾아서 부수는 로직 ...
}
요약
좀비는 **NearestAttackableTargetGoal**을 통해 주변 35블록(FOLLOW_RANGE) 안에 Player.class 등이 있는지 매 틱마다 감시합니다. 발견하는 즉시 **ZombieAttackGoal**이 실행되어 추격을 시작하는 것이 "선제 타격"의 원리입니다.
만약 질문자님의 다이아 좀비가 더 무섭게 느껴지게 하고 싶다면, FOLLOW_RANGE 속성 값을 35보다 훨씬 높게(예: 64.0D) 설정하면 아주 먼 곳에서도 플레이어를 발견하고 달려들게 됩니다.
사용자 정의 궁수(ArcherEntity)가 좀비를 선제 타격할 수 있는 이유는 ArcherEntity 클래스의 registerGoals() 메소드에서 설정한 AI 때문입니다.
제공해주신 코드 중 핵심이 되는 부분은 다음과 같습니다.
1. 타겟을 찾는 탐지기: NearestAttackableTargetGoal
ArcherEntity.java 파일의 43라인 부근을 보시면 이 코드가 있습니다:
// ArcherEntity.java 내부
@Override
protected void registerGoals() {
// ... 다른 골들 ...
// 몬스터만 선제 공격
this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Monster.class, true));
}
- targetSelector: 엔티티가 "누구를 공격할지" 결정하는 AI 목록입니다.
- NearestAttackableTargetGoal: 주변을 스캔하여 가장 가까운 적을 찾는 마인크래프트 기본 AI 클래스입니다.
- Monster.class: 공격 대상을 Monster 클래스로 지정했습니다. 마인크래프트에서 좀비(Zombie)는 Monster를 상속받고 있기 때문에, 궁수는 좀비를 발견하자마자 적으로 인식(선제 타격 준비)하게 됩니다.
2. 어떻게 공격할 것인가: VillagerSkirmishGoal
타겟이 정해지면 실제로 화살을 쏘는 동작은 goalSelector에 등록된 VillagerSkirmishGoal이 담당합니다.
// ArcherEntity.java 내부
this.goalSelector.addGoal(1, new VillagerSkirmishGoal(this, 1.0D, 20, 15.0F, 6.0F));
이 클래스(VillagerSkirmishGoal)의 tick() 메소드 로직을 보면 다음과 같은 과정이 일어납니다:
- 시야 확인: this.villager.getSensing().hasLineOfSight(this.attackTarget)를 통해 좀비가 보이는지 확인합니다.
- 거리 조절: 너무 가까우면 도망가고(fleeDistanceSqr), 너무 멀면 다가갑니다.
- 사격: 좀비가 사거리(attackRadiusSqr) 안에 들어오면 performSingleShot()을 호출하여 화살을 발사합니다.
3. 좀비의 ZombieAttackGoal과 다른 점
질문에서 보여주신 ZombieAttackGoal은 좀비 전용 근접 공격 AI입니다.
- 좀비: 플레이어를 발견하면 ZombieAttackGoal이 실행되어 "팔을 들어 올리고 근접 공격"을 하려고 달려듭니다.
- 궁수: 좀비를 발견하면 VillagerSkirmishGoal이 실행되어 "거리를 유지하며 화살"을 쏩니다.
요약: 왜 선제 타격이 가능한가?
- 인식: NearestAttackableTargetGoal이 주변에 Monster(좀비 포함)가 있는지 계속 감시합니다.
- 범위: 궁수의 속성(Attributes)에 설정된 탐색 범위 내에 좀비가 들어오면 즉시 타겟으로 삼습니다.
- 실행: 타겟이 잡히는 순간 VillagerSkirmishGoal이 작동하여 좀비를 향해 화살을 쏘기 시작합니다.
즉, targetSelector에 Monster.class를 타겟으로 등록했기 때문에 좀비를 보는 즉시 공격을 시작하는 것입니다. 만약 좀비가 궁수를 인식하는 거리(35블록)보다 궁수가 좀비를 인식하는 거리가 더 길다면, 좀비가 가만히 있어도 궁수가 먼저 화살을 쏠 수 있습니다.
NearestAttackableTargetGoal이 수많은 몹에게 적용되어 있음에도 서버 부하가 적은 이유는 매 틱마다 전수 조사를 하지 않기 때문이며, 내부적으로는 **공간 분할(Spatial Partitioning)**이라는 최적화 기법을 사용하기 때문입니다.
질문하신 내용에 대해 단계별로 분석해 드릴게요.
1. 왜 성능 부하가 적은가? (코드 분석)
제공해주신 코드의 canUse() 메서드에 핵심 비밀이 있습니다.
public boolean canUse() {
// randomInterval이 보통 10으로 설정됨
if (this.randomInterval > 0 && this.mob.getRandom().nextInt(this.randomInterval) != 0) {
return false; // 10번 중 9번은 탐색조차 안 하고 바로 종료 (부하 감소)
} else {
this.findTarget(); // 10%의 확률로 당첨된 틱에서만 탐색 실행
return this.target != null;
}
}
- 실행 빈도 조절: 모든 몹이 매 틱(0.05초)마다 주변을 스캔하면 서버가 터지겠지만, 이 코드는 보통 10틱(약 0.5초)에 한 번꼴로 탐색을 시도하도록 설계되어 있습니다.
- 분산 처리: 수백 마리의 몹이 있다면, 각자 nextInt(10)을 던지기 때문에 탐색 시점이 서로 분산됩니다. 덕분에 한꺼번에 부하가 몰리는 것을 방지합니다.
2. 내부적으로 어떻게 동작하는가? (탐색 알고리즘)
findTarget() 내부에서 호출하는 this.mob.level.getEntitiesOfClass(...)는 어떻게 동작할까요?
1)o(1)Map 방식인가?
아닙니다. 모든 좌표를 Key로 하는 Map은 엔티티가 움직일 때마다 Map을 업데이트해야 하므로 오히려 비용이 더 큽니다.
(2) DFS/BFS 방식인가?
아닙니다. 이는 경로 탐색(Pathfinding)에는 쓰이지만, 주변 엔티티 탐색에는 쓰이지 않습니다.
(3) 실제 방식: 공간 분할 (Spatial Partitioning / Grid System)
마인크래프트 월드는 16x16x16 크기의 "Section" (또는 Chunk의 일부) 단위로 나뉘어 관리됩니다.
- 섹션별 리스트: 각 섹션(구역)은 자기 구역 안에 있는 엔티티들의 리스트(EntitySection)를 가지고 있습니다.
- AABB 범위 계산: getTargetSearchArea()를 통해 몹 주변의 상자(AABB) 범위를 잡습니다. (예: 반경 35블록)
- 구역 선별: 그 상자가 걸쳐 있는 섹션들만 골라냅니다. (전체 월드가 아니라 주변 몇 개의 구역만)
- 필터링: 선택된 섹션 안에 있는 엔티티 리스트만 돌면서 다음을 확인합니다.
- targetType(예: Monster.class)에 해당하는가? (instanceof 체크)
- 살아있는가? 공격 가능한 상태인가? (TargetingConditions)
- 거리 비교: 조건에 맞는 애들 중 가장 가까운 놈을 선택합니다.
3. 정리: 성능이 좋은 이유 요약
- 시간적 최적화: randomInterval을 통해 탐색 횟수를 1/10로 줄임.
- 공간적 최적화: 월드 전체 엔티티를 뒤지는 이 아니라, 자신이 속한 섹션과 주변 섹션의 엔티티만 뒤짐.
- O(N)O(N)
- 데이터 구조: 마인크래프트는 엔티티가 움직일 때마다 EntitySection 리스트를 업데이트하는데, 이게 매우 효율적으로 짜여 있어 탐색 시점에 getEntitiesOfClass를 호출하면 이미 잘 정리된 리스트만 가져오면 됩니다.
결론
궁수가 좀비를 찾는 과정은 **"내 근처 구역(Section)에 등록된 몹 리스트 중에 Monster 타입이 있는지 0.5초마다 한 번씩 확인하는 것"**입니다. 전체를 다 뒤지는 DFS가 아니라, 잘 정돈된 사물함(Section) 중 내 근처 사물함만 열어보는 방식이라서 수십 마리가 있어도 렉이 거의 없는 것입니다.
마인크래프트와 같은 공간 분할(Spatial Partitioning) 방식에서 **"섹션 탐색"**이 구체적으로 어떻게 이루어지는지, 아주 쉬운 비유와 단계별 로직으로 설명해 드릴게요.
1. 직관적인 비유: 거대한 도서관에서 책 찾기
세상에 10,000마리의 엔티티(몹)가 있다고 가정해 봅시다.
- 방식 A (비효율): 10,000마리 전체 리스트를 처음부터 끝까지 다 읽으며 "내 옆에 있니?"라고 묻기. (너무 느림)
- 방식 B (섹션 탐색): 도서관을 1층, 2층, 3층... 그리고 각 층을 A구역, B구역, C구역으로 나눕니다. 내가 2층 C구역에 있다면, "2층 C구역과 그 주변 구역"에 있는 책꽂이만 확인합니다.
여기서 핵심은 **"내가 속한 구역의 번호를 수학적으로 바로 계산할 수 있다"**는 점입니다.
2. 구체적인 작동 단계 (내부 로직)
1단계: 좌표를 '섹션 번호'로 변환 (수학적 계산)
마인크래프트 섹션은 16x16x16 크기입니다. 어떤 엔티티가 (x: 35, y: 70, z: -10)에 있다고 하면, 컴퓨터는 복잡한 계산 없이 나누기 16만 해서 이 엔티티가 몇 번 섹션에 있는지 바로 압니다.
- 섹션 X: 35 / 16 = 2
- 섹션 Y: 70 / 16 = 4
- 섹션 Z: -10 / 16 = -1
- 결과: 이 엔티티는 (2, 4, -1)번 섹션 리스트에 이름표를 올립니다.
2단계: 탐색 범위(AABB) 설정
좀비가 주변 32블록 안의 플레이어를 찾는다고 합시다. 좀비 위치가 (32, 64, 32)라면, 탐색 범위는 다음과 같습니다.
- X: 0 ~ 64
- Y: 32 ~ 96
- Z: 0 ~ 64
3단계: 걸쳐 있는 섹션들만 골라내기 (핵심)
위의 탐색 범위를 다시 16으로 나눕니다.
- X 섹션 범위: 0/16 ~ 64/16 → 0번 ~ 4번 섹션
- Y 섹션 범위: 32/16 ~ 96/16 → 2번 ~ 6번 섹션
- Z 섹션 범위: 0/16 ~ 64/16 → 0번 ~ 4번 섹션
이제 시스템은 월드 전체를 뒤지는 게 아니라, X(04), Y(26), Z(0~4) 범위에 해당하는 섹션 주머니들만 열어봅니다.
4단계: 섹션 내 리스트 순회 (필터링)
선택된 섹션 주머니 안에는 그 구역에 살고 있는 엔티티들의 짧은 리스트가 들어있습니다.
- "너는 몬스터니?" (Type 체크)
- "너 아직 살아있니?" (Status 체크)
- "실제 거리가 32블록 이내니?" (정밀 거리 체크 - 피타고라스 정리)
3. 왜 이렇게 하나요? (성능의 이유)
- 검사 대상의 급감: 월드에 엔티티가 10,000마리 있어도, 내 주변 섹션 9
27개 정도만 뒤지면 실제 검사 대상은 1020마리로 줄어듭니다. - 업데이트의 간편함: 엔티티가 움직이다가 x 좌표가 15에서 16이 되는 순간, 15/16 = 0번 섹션에서 16/16 = 1번 섹션으로 리스트만 옮겨주면 됩니다. (Map 전체를 재구성하는 것보다 훨씬 빠름)
요약
섹션 탐색은 **"전체 데이터를 뒤지는 대신, 좌표를 16으로 나눈 몫을 이용해 내가 확인해야 할 '구역 주머니'들만 골라내어 그 안의 내용물만 확인하는 방식"**입니다.
이 과정에서 AABB(상자 범위)는 어떤 주머니들을 열어볼지 결정하는 기준이 됩니다.
질문의 의도를 완벽히 파악했습니다. **"그 특정 구역(섹션) 안에 도대체 엔티티 정보가 어떤 형태로 저장되어 있길래 DFS 같은 복잡한 알고리즘 없이 바로 꺼내오는가?"**가 궁금하신 거군요.
결론부터 말씀드리면, DFS는 전혀 쓰지 않습니다. 그 내부에는 아주 단순한 '리스트(List)' 혹은 **'타입별로 분류된 바구니(MultiMap)'**가 들어있습니다.
1. 섹션 내부의 정체: ClassInstanceMultiMap
마인크래프트 섹션(16x16x16)은 내부적으로 엔티티를 저장할 때, 그냥 "엔티티 뭉텅이"로 들고 있지 않습니다. 클래스(타입)별로 미리 분류된 가방에 담아둡니다.
개발자가 만든 커스텀 자료구조인 ClassInstanceMultiMap이라는 것을 사용하는데, 구조는 이렇습니다:
- 아이언 골렘 리스트: [골렘1, 골렘2]
- 좀비 리스트: [좀비1, 좀비2, 좀비3]
- 아이템 리스트: [사과1, 흙20]
2. 탐색이 일어나는 실제 과정 (내부 동작)
자, 이제 좀비가 "주변의 아이언 골렘"을 찾는다고 가정해봅시다.
- 섹션 접근: 좀비가 속한 섹션과 주변 섹션의 주소(메모리 번지)를 바로 찍어서 가져옵니다. (배열 인덱스 접근이라 O(1))
- 타입 필터링 (가장 중요):
- 좀비는 IronGolem.class를 찾고 싶어 합니다.
- 섹션에 가서 **"너네 골렘 가방(리스트)만 줘봐"**라고 합니다.
- 그러면 섹션은 좀비, 화살, 떨어져 있는 아이템 리스트는 쳐다보지도 않고, 골렘이 들어있는 리스트만 통째로 넘겨줍니다.
- 단순 반복 (Iteration):
- 넘겨받은 리스트 안에 골렘이 2마리 있다면, 딱 2번만 루프를 돕니다.
- "이 골렘이 내 사거리(AABB) 안에 있나?" -> 맞으면 타겟 후보 등록.
이게 끝입니다. DFS처럼 노드를 타고 들어가는 게 아니라, 이미 분류되어 있는 리스트를 그냥 한 번 쓱 훑는 것입니다.
3. 왜 DFS가 아니고 이 방식이 빠른가?
- DFS를 안 쓰는 이유: DFS는 "A가 B를 알고, B가 C를 아는" 관계망을 타고 갈 때 씁니다. 하지만 엔티티는 서로 연결된 게 아니라 그냥 공간(섹션)이라는 방 안에 덩그러니 놓여 있는 것입니다.
- 업데이트 비용: 엔티티가 움직일 때마다 이 리스트를 관리하는 게 힘들 것 같지만, 사실 1초에 20번(Tick) 일어나는 연산치고는 매우 가볍습니다. 엔티티가 섹션 경계를 넘을 때만 List.remove()와 List.add()를 해주면 되니까요.
4. 성능이 안 좋을 수가 없는 이유 (숫자로 보기)
- 전체 월드 엔티티: 2,000마리 (전부 검사하면 렉 발생)
- 주변 3x3x3 섹션 내 엔티티: 약 20~50마리
- 그중 내가 찾는 '타입(예: 골렘)' 엔티티: 0~3마리
좀비 입장에서는 단 3마리만 체크하면 탐색이 끝납니다. 1,000마리를 DFS로 뒤지는 게 아니라, **"이미 골렘만 모아둔 3개짜리 리스트"**를 받아서 3번만 확인하는 것이기 때문에 성능이 극도로 좋은 것입니다.
요약
- 섹션 안에는 엔티티가 타입별(Class)로 분류된 리스트 형태로 들어있다.
- 탐색 시에는 원하는 타입의 리스트만 골라잡는다.
- 그 리스트를 단순 for문으로 한 바퀴 돌며 거리만 체크한다.
- DFS 같은 복잡한 알고리즘은 필요 없다. (데이터가 이미 구역별/타입별로 '정리'되어 있기 때문)
네, 맞습니다. 정확히는 NearestAttackableTargetGoal 클래스 내부에 있는 "주변 탐색 로직(findTarget -> getNearestEntity)"을 사용하고 있기 때문에 선제 타격이 가능한 것입니다.
이 과정을 더 구체적으로 연결해 보면 다음과 같습니다.
1. "누가 먼저 발견하는가?"의 싸움
선제 타격은 결국 **"내 사거리 안에 적이 들어왔는가?"**를 누가 더 먼저, 더 자주 체크하느냐에 달려 있습니다.
- 궁수(Archer): NearestAttackableTargetGoal을 가지고 있으며, 대상이 Monster.class(좀비 포함)로 설정되어 있습니다.
- 좀비(Zombie): 역시 NearestAttackableTargetGoal을 가지고 있지만, 대상이 Player.class나 Villager.class 등으로 설정되어 있습니다.
궁수의 탐색 AI는 주기적으로(약 0.5초마다) **getNearestEntity**를 호출하여 주변에 좀비가 있는지 "레이더"를 돌립니다.
2. 선제 타격이 이루어지는 3단계 과정
- 탐색 (findTarget 단계):
궁수의 AI가 level.getNearestEntity()를 실행합니다. 이때 궁수의 FOLLOW_RANGE 속성(기본 주민 기반이면 약 16~48블록) 내에 있는 좀비를 "공격 대상"으로 확정합니다. - 타겟 확정 (setTarget 단계):
getNearestEntity가 좀비를 찾아내면, 궁수 엔티티의 내부 변수인 target에 그 좀비를 등록합니다. (this.mob.setTarget(this.target)) - 공격 개시 (VillagerSkirmishGoal 단계):
궁수의 또 다른 AI인 VillagerSkirmishGoal은 매 틱마다 "나한테 타겟이 있나?"를 체크합니다. 방금 좀비가 타겟으로 등록되었으므로, 좀비가 궁수를 보기도 전에 궁수는 즉시 활을 쏘기 시작합니다.
1. Monster.class란 무엇인가?
Monster.class는 마인크래프트 원본 코드(net.minecraft.world.entity.monster.Monster)에 정의된 "적대적 몹"들의 최상위 부모 클래스입니다.
마인크래프트의 몹 계층 구조를 보면 이해가 빠릅니다:
- Entity (모든 엔티티)
- LivingEntity (살아있는 생명체)
- Mob (AI를 가진 몹)
- PathfinderMob (길찾기 능력이 있는 몹)
- Monster (플레이어를 공격하는 일반적인 몬스터들의 부모)
- Zombie (좀비)
- Skeleton (스켈레톤)
- Creeper (크리퍼)
- Spider (거미)
- ... 등등
- Monster (플레이어를 공격하는 일반적인 몬스터들의 부모)
- PathfinderMob (길찾기 능력이 있는 몹)
- Mob (AI를 가진 몹)
- LivingEntity (살아있는 생명체)
2. 사용자 정의 좀비도 포함하나요?
네, 포함됩니다.
질문자님이 만든 새로운 좀비 클래스가 다음과 같이 작성되었다면 무조건 Monster에 포함됩니다:
- public class MyCustomZombie extends Zombie (좀비를 상속받은 경우)
- public class MyCustomZombie extends Monster (몬스터를 직접 상속받은 경우)
따라서 궁수의 AI 코드인 new NearestAttackableTargetGoal<>(this, Monster.class, true)는 마크 원본 좀비뿐만 아니라, 질문자님이 모드로 추가한 모든 "몬스터류" 몹들을 전부 타겟팅하게 됩니다.
3. 문제 발생: 아군 좀비(IZombieAlly)도 공격하는가?
네, 지금 상태로는 공격합니다.
이게 현재 코드의 가장 중요한 포인트입니다.
- 질문자님의 아군 좀비가 Zombie를 상속받았다면, 그 좀비는 Monster 타입이기도 합니다.
- 궁수는 "모든 Monster를 공격해라"라는 명령(NearestAttackableTargetGoal)을 수행 중입니다.
- 비록 IZombieAlly 인터페이스를 붙여줬더라도, 궁수의 타겟팅 AI(NearestAttackableTargetGoal) 부분에서 이를 제외하는 로직이 없다면 궁수는 자기 아군인 좀비를 보고 "어? 몬스터다!" 하고 화살을 쏩니다.
4. 해결 방법 (궁수가 아군 좀비를 선제 타격하지 않게 하기)
궁수의 registerGoals에서 NearestAttackableTargetGoal을 등록할 때, **"몬스터이긴 한데, 아군(IZombieAlly)은 제외해라"**라는 조건을 추가해야 합니다.
NearestAttackableTargetGoal의 생성자 중에는 **Predicate(조건식)**를 받는 버전이 있습니다. 이를 사용하면 됩니다.
[ArcherEntity.java 수정 예시]
// ArcherEntity.java의 registerGoals 부분
this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Monster.class, 10, true, false,
(entity) -> {
// 1. 기본적으로 Monster여야 함
if (!(entity instanceof Monster)) return false;
// 2. 하지만 IZombieAlly를 구현한 아군 좀비라면 타겟에서 제외(false 반환)
if (entity instanceof IZombieAlly) return false;
// 3. 그 외의 일반 몬스터는 공격 대상으로 인정(true 반환)
return true;
}
));
네, 사용자 정의 좀비가 Zombie 클래스를 상속받았다면 결과적으로 Monster 클래스도 상속받고 있는 것이 맞습니다.
상속 구조를 따라가 보면 다음과 같습니다.
1. 상속 계층 구조 (Inheritance Hierarchy)
마인크래프트의 클래스 구조는 아래와 같이 연결됩니다.
- Monster: (방금 보여주신 클래스) 모든 적대적 몹의 부모.
- Zombie: public class Zombie extends Monster (좀비는 몬스터를 상속함)
- 사용자 정의 좀비: public class MyCustomZombie extends Zombie (사용자 좀비는 좀비를 상속함)
결과적으로 사용자 정의 좀비는 Monster의 자식의 자식이 되므로, Java의 다형성 원리에 따라 Monster 타입에 포함됩니다.
2. 질문하신 "다이아 좀비"의 경우는 어떠한가?
처음에 보여주신 다이아 좀비(Diamond Zombie) 코드를 보면, 새로운 클래스를 만드는 대신 기존 좀비에 **Capability(데이터 태그)**를 붙이는 방식을 사용하셨습니다.
// DiamondZombieEvents.java 중에서
if (event.getObject() instanceof Zombie) {
// 기존 마인크래프트 좀비 객체에 데이터를 추가함
}
이 경우, 이 다이아 좀비의 실제 객체는 마인크래프트 원본인 net.minecraft.world.entity.monster.Zombie입니다. 그리고 위에서 설명했듯이 Zombie 클래스는 Monster를 상속받고 있습니다.
따라서 다이아 좀비는 Monster 클래스입니다.
3. 왜 이게 중요한가? (궁수 AI와의 관계)
궁수의 AI 코드에서 선제 타격 대상을 설정할 때 다음과 같이 하셨죠?
// ArcherEntity.java
this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Monster.class, true));
- 궁수는 "주변에 Monster 클래스(또는 그 자식들)가 있는지 찾아라"라는 명령을 받았습니다.
- 다이아 좀비(또는 사용자 좀비)는 Zombie를 거쳐 **Monster**를 상속받고 있습니다.
- 그러므로 궁수의 레이더(getNearestEntity)에 사용자 좀비가 Monster 타입으로 걸려들게 됩니다.
결론
네, 상속받고 있습니다.
사용자 정의 좀비가 Zombie 클래스를 기반으로 만들어졌다면, 자동으로 Monster 클래스의 모든 속성을 물려받으며 궁수의 공격 대상(Monster.class)에 포함되게 됩니다.
반면, **궁수(ArcherEntity)**는 AbstractVillager를 상속받고 Monster를 상속받지 않았기 때문에, 좀비들이 궁수를 먼저 공격하려면 좀비의 AI에 AbstractVillager.class를 타겟으로 넣어야 하는 것입니다.
Monster 클래스
1. 정체성 정의 (Identity)
- 상속 구조: PathfinderMob을 상속받습니다. 즉, "길 찾기 AI" 기능을 기본적으로 가지고 있다는 뜻입니다.
- Enemy 인터페이스: 이 클래스는 Enemy라는 인터페이스를 구현합니다. 시스템이 "이 놈이 적인가?"를 판단할 때 이 인터페이스를 체크합니다.
- XP 보상: this.xpReward = 5; 기본적으로 몬스터를 죽였을 때 경험치 구슬 5개를 드롭하도록 설정되어 있습니다.
2. 환경 및 스폰 규칙 (Spawning Logic) - 가장 중요한 부분
이 클래스의 많은 부분이 **"몬스터가 어디서 태어날 수 있는가?"**에 집중되어 있습니다.
- isDarkEnoughToSpawn (스폰에 충분히 어두운가?):
- 하늘 빛(LightLayer.SKY)과 블록 빛(LightLayer.BLOCK)을 모두 체크합니다.
- 단순히 "어둡다"가 아니라 random.nextInt(32) 등을 활용해 확률적으로 체크하며, 천둥이 칠 때는 기준이 완화되는 로직이 들어있습니다.
- checkMonsterSpawnRules:
- 난이도가 PEACEFUL(평화로움)이 아니어야 함.
- 위의 어둠 조건(isDarkEnoughToSpawn)을 만족해야 함.
- getWalkTargetValue (길찾기 선호도):
- 0.5F - brightness를 반환합니다. 즉, 밝을수록 선호도가 낮고 어두울수록 선호도가 높습니다. 몬스터가 왜 어두운 곳으로 자꾸 기어 들어가는지 보여주는 코드입니다.
3. AI 및 상태 업데이트
- updateNoActionTime:
- 주변 밝기가 0.5보다 밝으면 noActionTime을 더 빨리 증가시킵니다.
- 이 수치가 높아지면 몬스터가 자연적으로 소멸(Despawn)할 확률이 높아집니다. 즉, 낮에 몬스터가 사라지는 원리 중 하나입니다.
- aiStep: 매 틱마다 실행되며, 휘두르는 애니메이션 시간 등을 업데이트합니다.
4. 사운드 및 상호작용
- 사운드 소스: SoundSource.HOSTILE로 설정되어 있어, 플레이어가 설정에서 "적대적 생명체 소리"를 줄이면 이 클래스를 상속받는 모든 몹의 소리가 줄어듭니다.
- 침대 방해: isPreventingPlayerRest가 true입니다. "근처에 몬스터가 있어 잠을 잘 수 없습니다"라는 메시지가 뜨는 이유가 바로 이 메서드 때문입니다.
5. 전투 보조 (Projectile Handling)
- getProjectile:
- 이 몬스터가 활이나 석궁 같은 발사형 무기를 들고 있을 때, 인벤토리에서 화살을 찾는 로직입니다.
- 만약 화살이 없다면 기본 화살(Items.ARROW)을 생성해서 쏘도록 구현되어 있습니다 (스켈레톤이 화살 없이도 무한정 쏘는 이유).
요약: 이전 질문(탐색)과의 연결고리
- 타겟팅 대상: 좀비 AI가 적을 찾을 때 NearestAttackableTargetGoal<Player>를 사용한다면, 이 Monster 클래스는 타겟이 아닌 **"주체(나 자신)"**가 됩니다.
- 필터링: 만약 아이언 골렘의 AI가 주변의 몬스터를 찾는다면, getEntitiesOfClass(Monster.class, ...)를 호출할 것입니다. 이때 이 클래스를 상속받은 모든 좀비, 스켈레톤들이 섹션 내 리스트에서 필터링되어 골렘의 타겟 후보가 됩니다.
네, 가능합니다! 아주 좋은 접근입니다. 이론적인 설명과 함께 어떻게 코드로 구현하는지 정리해 드릴게요.
1. 이론적 설명: 인터페이스를 매개변수로 줄 수 있는가?
결론부터 말씀드리면 "네, 가능합니다."
Java에서 Class<T> 타입의 매개변수는 클래스(Zombie.class)뿐만 아니라 **인터페이스(IZombieAlly.class)**도 받을 수 있습니다. 마인크래프트의 엔티티 탐색 시스템은 내부적으로 다음과 같이 동작하기 때문입니다.
- 타입 체크 (isInstance):
마인크래프트가 주변 엔티티를 검색할 때, targetType.isInstance(entity)라는 메서드를 사용합니다.- 만약 targetType이 클래스(Monster.class)라면: 해당 클래스를 상속받았는지 확인합니다.
- 만약 targetType이 인터페이스(IZombieAlly.class)라면: 해당 엔티티가 그 인터페이스를 구현(implements) 했는지 확인합니다.
- 유연성:
인터페이스를 타겟으로 잡으면, 좀비든 스켈레톤이든 혹은 아예 새로운 형태의 몹이든 상관없이 IZombieAlly 인터페이스만 붙어 있으면 모두 공격 대상으로 삼을 수 있습니다. 이것이 인터페이스 기반 설계의 강력한 장점입니다.
2. 코드로 구현하는 방법
궁수(ArcherEntity)의 AI를 다음과 같이 수정하면 됩니다.
방법 A: IZombieAlly를 구현한 개체만 정밀 타격하기
이 방식은 Monster.class 여부와 상관없이 오직 IZombieAlly 표식이 붙은 애들만 찾습니다.
// ArcherEntity.java의 registerGoals 내부
this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(
this,
IZombieAlly.class, // 타겟 타입을 인터페이스로 지정!
true // 시야에 보여야 함
));
방법 B: 몬스터 중에서 IZombieAlly인 경우만 공격하기 (더 안전함)
만약 IZombieAlly가 LivingEntity만 상속받고 있다면, 혹시 모를 오작동을 방지하기 위해 Monster 중에서 필터링하는 방식을 쓸 수도 있습니다.
this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(
this,
Monster.class, // 일단 모든 몬스터를 뒤지는데
10, // 탐색 주기
true, // 시야 확인
false, // 가까운 순서 정렬 여부
(entity) -> entity instanceof IZombieAlly // 그 중에서 IZombieAlly인 놈만 골라라!
));
>이건 구린긋, Monster아닐수도잇는데
3. 주의사항 (중요!)
인터페이스를 NearestAttackableTargetGoal의 타겟 클래스로 넘길 때, 한 가지만 체크하시면 됩니다.
IZombieAlly 인터페이스가 LivingEntity를 상속받고 있는가?
NearestAttackableTargetGoal<T extends LivingEntity>는 제네릭 제약 조건이 걸려 있습니다. 따라서 인터페이스 정의 시 다음과 같이 되어 있어야 합니다.
// IZombieAlly.java
public interface IZombieAlly extends IMyModEntity {
// IMyModEntity가 LivingEntity를 상속받지 않는다면,
// 여기서 직접 LivingEntity를 상속받거나 IMyModEntity를 확인해야 합니다.
}
만약 IMyModEntity가 단순히 마커 인터페이스이고 LivingEntity와 연관이 없다면, 골 선언 시 에러가 날 수 있습니다. 이럴 때는 방법 B처럼 Monster.class를 기반으로 하고 람다식(instanceof)으로 필터링하는 것이 가장 깔끔하고 에러가 없습니다.
요약
- 인터페이스도 .class를 통해 매개변수로 전달 가능합니다.
- 마인크래프트 AI는 instanceof와 유사한 방식으로 타겟을 찾으므로 인터페이스 구현 여부를 정확히 판단합니다.
- 장점: 새로운 적대적 좀비 모드를 추가할 때, 코드 수정 없이 인터페이스만 붙여주면 궁수가 즉시 알아보고 공격합니다. (확장성이 매우 좋아짐)
Java에서 인터페이스는 클래스(LivingEntity)를 상속(extends)할 수 없습니다. 인터페이스는 오직 다른 인터페이스만 상속할 수 있습니다.
따라서 IZombieAlly가 LivingEntity가 아니기 때문에 NearestAttackableTargetGoal의 두 번째 인자에 IZombieAlly.class를 직접 넣으면 제네릭 타입 오류(컴파일 에러)가 발생합니다.
[ ok ]
엥?
그러면 추상클래스로 선언해야하나
문제없다네
"왜 굳이 LivingEntity라는 제약에 갇혀야 하는가?"와 "추상 클래스가 더 깔끔하지 않은가?"에 대한 이론적 배경과 마인크래프트 구조상의 한계를 설명해 드릴게요.
1. 이론적 설명: 왜 LivingEntity를 벗어날 수 없는가?
마인크래프트 엔진(바닐라 코드)의 구조상, **"AI를 가지고 체력이 있으며 공격을 주고받을 수 있는 모든 존재"**는 이미 LivingEntity를 상속받도록 설계되어 있습니다.
- 엔티티의 계층 구조:
Entity (좌표만 있음) -> LivingEntity (체력, 데미지 개념 추가) -> Mob (AI 추가) -> Monster/Zombie/Villager - AI의 한계: 마크의 Goal 시스템(탐색 레이더)은 LivingEntity가 아닌 것(예: 떨어지는 블록, 아이템 엔티티)을 타겟으로 잡을 수 없습니다. 타겟의 위치를 추적하고 데미지를 입히는 모든 내부 로직이 LivingEntity 메서드를 호출하기 때문입니다.
즉, **"새로 만드는 좀비가 항상 LivingEntity여야 한다"**는 것은 선택이 아니라, 마인크래프트에서 "살아있는 몹"을 만들기 위한 필수 조건입니다.
2. 추상 클래스 vs 인터페이스 (상속의 딜레마)
사용자님이 제안하신 **"상위 추상 클래스(예: AbstractModZombie)"**를 사용하는 방법은 설계상 깔끔해 보이지만, Java의 단일 상속 제약 때문에 치명적인 문제에 부딪힙니다.
추상 클래스 사용 시의 문제 (단일 상속의 늪):
만약 AbstractModZombie라는 추상 클래스를 만들었다고 가정해 봅시다.
- 사용자 좀비 A가 **일반 좀비의 특성(햇빛에 타기, 주민 공격)**을 그대로 갖고 싶다면 extends Zombie를 해야 합니다.
- 그런데 사용자 좀비 A가 아군 시스템에 편입되려면 extends AbstractModZombie도 해야 합니다.
- Java는 두 개의 클래스를 동시에 상속받을 수 없습니다.
결국, Zombie 클래스의 기능을 포기하고 밑바닥부터 다시 만들거나, 상속 구조가 꼬이게 됩니다.
인터페이스(IZombieAlly) 사용 시의 이점:
- 자유로운 상속: 어떤 좀비는 Zombie를 상속받고, 어떤 좀비는 Skeleton을 상속받아도, 그 뒤에 implements IZombieAlly만 붙이면 됩니다.
- 다형성(Polymorphism): 법사 AI 입장에서는 상대방이 좀비인지, 스켈레톤인지, 아니면 완전히 새로운 괴물인지 알 필요가 없습니다. 오직 **"IZombieAlly라는 이름표를 달고 있는가?"**만 확인하면 됩니다.
3. 가장 권장되는 설계: "인터페이스 + 추상 클래스" 혼합
사용자님이 말씀하신 "추상 클래스가 낫지 않나"라는 생각도 맞습니다. 코드 중복을 줄여주기 때문이죠. 그래서 실제 모드 개발에서는 보통 다음과 같은 하이브리드 방식을 씁니다.
- 인터페이스 (IZombieAlly): "법사의 탐색용 레이더" 역할 (모든 아군 좀비 세력을 하나로 묶음)
- 추상 클래스 (BaseCustomZombie): "코드 중복 방지" 역할 (내 모드에서 만드는 좀비들의 공통 로직 저장)
// 1. 레이더용 인터페이스 (법사 AI가 사용)
public interface IZombieAlly { }
// 2. 코드 중복 방지용 추상 클래스 (내가 직접 만드는 좀비들이 사용)
public abstract class BaseCustomZombie extends Zombie implements IZombieAlly {
// 여기서 공통 체력 설정, 공통 AI 등을 처리
}
// 3. 실제 좀비 구현
public class MyDiamondZombie extends BaseCustomZombie {
// 다이아 좀비만의 특수 기능만 구현
}
4. 결론
"항상 LivingEntity여야 하는 것"은 마인크래프트라는 플랫폼의 규칙(Rule)입니다. 이 규칙 안에서 가장 유연하게 몹들을 식별하려면 인터페이스를 사용하는 것이 Java 설계 원칙(Favor Composition/Interfaces over Inheritance)에 부합합니다.
법사 V3 코드에서 LivingEntity.class를 먼저 잡고 instanceof IZombieAlly를 체크하는 이유는:
"일단 살아있는 모든 것(LivingEntity) 중에서, 내가 원하는 특정 표식(IZombieAlly)을 가진 놈만 골라내겠다"는 가장 가볍고 확실한 필터링 방법이기 때문입니다.
만약 AbstractModZombie라는 추상 클래스만 사용하게 되면, 나중에 "좀비를 상속받지 않은 다른 형태의 적"을 아군 세력에 넣고 싶을 때 다시 코드를 다 갈아엎어야 하는 상황이 올 수 있습니다.
만약 그냥 Villager.createAttributes()를 썼다면 **인식 거리가 마크 기본값(16블록 미만)**이라서 선제 타격을 거의 못 할 수 있습니다. 이 코드를 수정하면 법사의 눈이 훨씬 밝아질 것입니다.
onAttributeCreation 이벤트에서 WizardEntityV3.createAttributes()를 호출하도록 수정하는 것이 매우 중요합니다.
그 이유와 역할에 대해 핵심만 콕 집어서 설명해 드릴게요.
1. 역할이 무엇인가?
onAttributeCreation 이벤트는 마인크래프트 엔진에게 **"이 엔티티(법사3)의 기본 능력치(스펙)가 뭐야?"**라고 물어볼 때 답해주는 곳입니다.
여기서 결정되는 능력치는 다음과 같습니다:
- 체력(MAX_HEALTH): 얼마나 튼튼한가?
- 이동 속도(MOVEMENT_SPEED): 얼마나 빠른가?
- 인식 거리(FOLLOW_RANGE): (가장 중요) 얼마나 멀리 있는 적을 "눈"으로 볼 수 있는가?
2. 왜 Villager.createAttributes()를 쓰면 안 되나?
현재 질문자님의 코드에서는 Villager.createAttributes()를 쓰고 있습니다.
- **주민(Villager)**은 평화로운 몹입니다. 멀리 있는 적을 찾아 공격할 필요가 없기 때문에 인식 거리가 매우 짧게 설정되어 있습니다.
- 법사3이 이 설정을 그대로 쓰면, 아무리 AI 레이더(NearestAttackableTargetGoal)를 돌려도 눈 자체가 근시라서 바로 앞에 좀비가 오기 전까지는 적으로 인식을 못 합니다.
3. 왜 WizardEntityV3.createAttributes()를 써야 하나?
앞서 WizardEntityV3 클래스 안에 제가 추가해드린 createAttributes() 메소드에는 다음 코드가 들어있습니다
.add(Attributes.FOLLOW_RANGE, 35.0D) // 인식 거리를 35블록으로 확장
이렇게 해줘야 마법사가 **"아, 나는 35블록 안에 있는 IZombieAlly를 찾아서 쏴야 하는구나"**라고 자기 시야를 확보하게 됩니다. 선제 타격의 핵심은 바로 이 '시야 확보'입니다.
제공해주신 DiamondZombieEvents.java를 보면 다이아 좀비를 이렇게 생성합니다:
// DiamondZombieEvents.java
public static void spawnDiamondZombie(Level level, BlockPos spawnPos, DiamondZombieType type) {
Zombie diamondZombie = new Zombie(EntityType.ZOMBIE, level); // <--- 일반 마크 좀비 객체 생성
// ...
diamondZombie.getCapability(DiamondZombieDataProvider.DIAMOND_ZOMBIE_DATA).ifPresent(data -> {
data.setType(type); // 데이터(태그)만 붙임
});
}
- 실제 객체: net.minecraft.world.entity.monster.Zombie (마인크래프트 원본 클래스)
- 구조: 일반 좀비 몸에 DiamondZombieData라는 **포스트잇(Capability)**만 붙여둔 상태입니다.
- 결과: 일반 좀비 클래스는 IZombieAlly를 구현하지 않았으므로, 마법사의 instanceof IZombieAlly 체크에서 **거짓(false)**이 나옵니다. 그래서 마법사가 공격하지 않습니다.
걍
다이아 구속좀비가 문제엿던거네
[ ]법사 능력치 원래대로 바꾸어놓기
- 인터페이스 메소드 문제: Java 8부터 인터페이스에 static 메소드를 넣을 수 있지만, 모딩 환경에서는 ZombieEnemyHandler 같은 별도의 유틸리티 클래스를 만드는 것이 관리가 훨씬 편합니다. (여러 인터페이스를 체크해야 할 수도 있기 때문)
[ ]
잘되는지 체크
'모딩 > 마인크래프트 모드 개발 일지' 카테고리의 다른 글
| 마크 모딩) "천사강림" (1) | 2025.12.26 |
|---|---|
| 마크모딩) 선제공격 안하고 관전하는 법사.. (1) | 2025.12.25 |
| 마크모딩) 사용자정의 법사 만들기(3d 엔티티로) (0) | 2025.12.24 |
| 마크모딩) 스스로 발전하는 마을주민 만들기 #3 (0) | 2025.12.24 |
| 마크모딩) 스스로 발전하는 마을주민 만들기 #2 (1) | 2025.12.23 |