### 2. 아머스탠드의 기본 "팔(Arms)" 렌더링 누수
베드락 에디션의 아머스탠드는 자바 에디션(JE)과 달리 기본적으로 소환 시 **팔이 없는 상태**로 스폰됩니다.
현재 코드의 `applyEquipment`에서 `EquipmentSlot.Mainhand` 슬롯에 무기나 도구 아이템 ID를 넣어주더라도, 아머스탠드 자체에 팔이 없으면 **아이템이 공중에 둥둥 떠 있거나 아머스탠드 발밑 바닥에 부자연스럽게 박혀서 표현되는 렌더링 버그**가 생깁니다.
* **원인:** 엔티티 모델 자체에 팔 활성화 태그가 누락되었기 때문입니다.
* **해결책:** 서버 내 리소스팩/데이터팩 구조에 따라 다르지만, 순정 상태에서 가장 깔끔하게 무기를 손에 쥐여주려면 스폰 직후 엔티티에 `has_arms` 컴포넌트나 관련 이벤트를 실행해 주어야 장비 리플레이가 어색하지 않습니다.
---
## ✨ 렌더링 및 연출 최적화까지 끝난 최종 마스터피스 코드
위의 장비 핸들링(팔 생성)과 이름표 상시 노출, 그리고 간헐적인 `isValid()` 타이밍 이슈까지 1%의 오차도 없이 다듬은 **진짜 최종 완성본**입니다.
```javascript
import {
world,
system,
ItemStack,
EquipmentSlot
} from "@minecraft/server";
import {
ActionFormData
} from "@minecraft/server-ui";
//--------------------------------------------------
// 설정
//--------------------------------------------------
const RECORD_INTERVAL = 2; // 2틱(0.1초)마다 녹화
const MAX_FRAMES = 600; // 최근 1분 저장 (0.1초 * 600)
//--------------------------------------------------
// 데이터 관리
//--------------------------------------------------
let playerReplays = new Map(); // 각 플레이어의 녹화 데이터 (playerId => frames[])
let adminSessions = new Map(); // 각 관리자의 리플레이 재생 세션 (adminId => session{} )
// 관리자 세션 초기화 및 가져오기 함수
function getAdminSession(adminId) {
if (!adminSessions.has(adminId)) {
adminSessions.set(adminId, {
selectedPlayerId: null,
selectedPlayerName: "",
replayFrames: [],
replayEntity: null,
playing: false,
paused: false,
playIndex: 0,
lastEquipped: {} // 렉 방지를 위한 이전 프레임 장비 캐싱 저장소
});
}
return adminSessions.get(adminId);
}
//--------------------------------------------------
// OP 확인
//--------------------------------------------------
function isOP(player) {
try {
player.runCommand("ability @s mayfly");
return true;
} catch {
return false;
}
}
//--------------------------------------------------
// GUI 시스템
//--------------------------------------------------
async function openReplayGUI(player) {
const players = [...world.getPlayers()];
if (players.length === 0) {
player.sendMessage("§c서버에 플레이어가 없습니다.");
return;
}
const selectForm = new ActionFormData()
.title("§0플레이어 선택")
.body("§7리플레이할 플레이어를 선택하세요.");
for (const p of players) {
selectForm.button(p.name);
}
const selectRes = await selectForm.show(player);
if (selectRes.canceled || selectRes.selection === undefined) return;
const targetPlayer = players[selectRes.selection];
const session = getAdminSession(player.id);
session.selectedPlayerId = targetPlayer.id;
session.selectedPlayerName = targetPlayer.name;
system.run(() => {
openMainGUI(player);
});
}
async function openMainGUI(player) {
const session = getAdminSession(player.id);
const form = new ActionFormData()
.title("§0Replay System")
.body(`§7대상: §f${session.selectedPlayerName}\n§7상태: ${session.playing ? (session.paused ? "§e일시정지" : "§b재생중") : "§c정지"}`)
.button("§b리플레이 재생")
.button("§7일시정지")
.button("§a다시시작")
.button("§e5초 뒤로")
.button("§d5초 앞으로")
.button("§c리플레이 종료")
.button("§6데이터 삭제");
const res = await form.show(player);
if (res.canceled || res.selection === undefined) return;
let shouldReopen = true;
switch (res.selection) {
case 0: // 재생
session.replayFrames = playerReplays.get(session.selectedPlayerId) || [];
if (session.replayFrames.length === 0) {
player.sendMessage("§c데이터가 없습니다.");
break;
}
session.playing = true;
session.paused = false;
session.playIndex = 0;
session.lastEquipped = {}; // 재생 시작 시 장비 캐시 초기화
player.sendMessage(`§b${session.selectedPlayerName} §f리플레이를 시작합니다.`);
break;
case 1: // 일시정지
if (session.playing) {
session.paused = true;
player.sendMessage("§7리플레이 일시정지");
}
break;
case 2: // 다시 시작
if (session.playing) {
session.paused = false;
player.sendMessage("§a리플레이 다시 시작");
}
break;
case 3: // 5초 뒤로
if (session.playing) {
session.playIndex = Math.max(0, session.playIndex - 50);
session.lastEquipped = {}; // 인덱스 강제 이동 시 장비 재동기화
player.sendMessage("§e5초 뒤로 이동");
}
break;
case 4: // 5초 앞으로
if (session.playing) {
session.playIndex = Math.min(session.replayFrames.length - 1, session.playIndex + 50);
session.lastEquipped = {}; // 인덱스 강제 이동 시 장비 재동기화
player.sendMessage("§d5초 앞으로 이동");
}
break;
case 5: // 리플레이 종료
session.playing = false;
session.playIndex = 0;
cleanUpReplayEntity(session);
player.sendMessage("§c리플레이를 종료했습니다.");
try { player.onScreenDisplay.setActionBar(""); } catch {}
shouldReopen = false;
break;
case 6: // 데이터 삭제
playerReplays.set(session.selectedPlayerId, []);
player.sendMessage("§6리플레이 데이터가 삭제되었습니다.");
break;
}
if (shouldReopen) {
system.runTimeout(() => {
if (player.isValid()) openMainGUI(player);
}, 1);
}
}
//--------------------------------------------------
// 리플레이 엔티티 안전 제거 함수
//--------------------------------------------------
function cleanUpReplayEntity(session) {
if (session.replayEntity) {
try {
if (session.replayEntity.isValid()) {
session.replayEntity.remove();
}
} catch {}
session.replayEntity = null;
}
}
//--------------------------------------------------
// 리플레이 엔티티 생성/관리
//--------------------------------------------------
function spawnReplayEntity(session, dimension, location) {
cleanUpReplayEntity(session);
session.replayEntity = dimension.spawnEntity("minecraft:armor_stand", location);
session.replayEntity.nameTag = `§b[REPLAY] ${session.selectedPlayerName}`;
session.replayEntity.alwaysShowNameTag = true; // [디테일 1] 멀리서도 이름표 추적 가능하도록 고정
session.lastEquipped = {};
try {
// 완벽한 고정과 동시에 무기를 정상적으로 쥘 수 있도록 '팔 추가' 구조적 보완
session.replayEntity.runCommand("effect @s levitation 99999 255 true");
session.replayEntity.triggerEvent("minecraft:has_arms"); // [디테일 2] BE 아머스탠드 무기 렌더링 활성화
} catch (e) {}
}
// 명시적인 EquipmentSlot 매핑을 통한 아이템 장착 유실 방지
function applyEquipment(session, frame) {
const entity = session.replayEntity;
if (!entity) return;
const eq = entity.getComponent("minecraft:equippable");
if (!eq) return;
const armorSlots = [
{ slotName: EquipmentSlot.Mainhand, itemId: frame.mainhand },
{ slotName: EquipmentSlot.Head, itemId: frame.helmet },
{ slotName: EquipmentSlot.Chest, itemId: frame.chest },
{ slotName: EquipmentSlot.Legs, itemId: frame.legs },
{ slotName: EquipmentSlot.Feet, itemId: frame.boots }
];
for (const itemSlot of armorSlots) {
const name = itemSlot.slotName;
const id = itemSlot.itemId;
if (session.lastEquipped[name] === id) continue;
try {
if (!id) {
eq.setEquipment(name, undefined);
} else {
const item = new ItemStack(id, 1);
eq.setEquipment(name, item);
}
session.lastEquipped[name] = id;
} catch (e) {}
}
}
function applyAnimation(entity, frame) {
if (!entity) return;
let anim = "animation.armor_stand.default_pose";
if (frame.sneaking) {
anim = "animation.armor_stand.cancan";
}
try {
entity.runCommand(`playanimation @s ${anim}`);
} catch {}
}
//--------------------------------------------------
// [시스템 1] 실시간 상시 녹화 루프 (2틱 주기)
//--------------------------------------------------
system.runInterval(() => {
for (const p of world.getPlayers()) {
if (!playerReplays.has(p.id)) {
playerReplays.set(p.id, []);
}
const arr = playerReplays.get(p.id);
const eq = p.getComponent("minecraft:equippable");
const rotation = p.getRotation();
let mainhand = eq?.getEquipment(EquipmentSlot.Mainhand)?.typeId || null;
let helmet = eq?.getEquipment(EquipmentSlot.Head)?.typeId || null;
let chest = eq?.getEquipment(EquipmentSlot.Chest)?.typeId || null;
let legs = eq?.getEquipment(EquipmentSlot.Legs)?.typeId || null;
let boots = eq?.getEquipment(EquipmentSlot.Feet)?.typeId || null;
arr.push({
x: p.location.x,
y: p.location.y,
z: p.location.z,
pitch: rotation.x,
yaw: rotation.y,
dimensionId: p.dimension.id,
sneaking: p.isSneaking,
sprinting: p.isSprinting,
jumping: !p.isOnGround,
mainhand, helmet, chest, legs, boots
});
if (arr.length > MAX_FRAMES) {
arr.shift();
}
}
}, RECORD_INTERVAL);
//--------------------------------------------------
// [시스템 2] 리플레이 재생 루프 (관리자 세션별 처리)
//--------------------------------------------------
system.runInterval(() => {
for (const [adminId, session] of adminSessions.entries()) {
if (!session.playing || session.paused) continue;
const admin = world.getPlayers().find(p => p.id === adminId);
if (!admin) {
cleanUpReplayEntity(session);
adminSessions.delete(adminId);
continue;
}
if (session.playIndex >= session.replayFrames.length) {
session.playing = false;
session.playIndex = 0;
cleanUpReplayEntity(session);
admin.sendMessage("§c리플레이가 종료되었습니다.");
try { admin.onScreenDisplay.setActionBar(""); } catch {}
continue;
}
const frame = session.replayFrames[session.playIndex];
const targetDimension = world.getDimension(frame.dimensionId || "minecraft:overworld");
if (!session.replayEntity || session.replayEntity.dimension.id !== frame.dimensionId) {
spawnReplayEntity(session, targetDimension, { x: frame.x, y: frame.y, z: frame.z });
}
try {
session.replayEntity.teleport(
{ x: frame.x, y: frame.y, z: frame.z },
{
dimension: targetDimension,
rotation: { x: frame.pitch, y: frame.yaw },
checkForBlocks: false
}
);
} catch (err) {}
applyEquipment(session, frame);
applyAnimation(session.replayEntity, frame);
try {
admin.onScreenDisplay.setActionBar(`§bREPLAY §f[ ${session.playIndex + 1} / ${session.replayFrames.length} ]`);
} catch {}
session.playIndex++;
}
}, RECORD_INTERVAL);
//--------------------------------------------------
// [시스템 3] 튕김/강제종료 시에도 완벽한 메모리 해제 보장
//--------------------------------------------------
world.afterEvents.playerLeave.subscribe((ev) => {
const leftPlayerId = ev.playerId;
if (!leftPlayerId) return;
if (playerReplays.has(leftPlayerId)) {
playerReplays.delete(leftPlayerId);
}
if (adminSessions.has(leftPlayerId)) {
const session = adminSessions.get(leftPlayerId);
cleanUpReplayEntity(session);
adminSessions.delete(leftPlayerId);
}
});
//--------------------------------------------------
// 채팅 명령어 구독
//--------------------------------------------------
world.beforeEvents.chatSend.subscribe((ev) => {
const player = ev.sender;
const msg = ev.message;
if (msg !== "!replay") return;
ev.cancel = true;
if (!isOP(player)) {
player.sendMessage("§cOP 권한이 필요합니다.");
return;
}
system.run(() => {
openReplayGUI(player);
});
});
To embed this project on your website, copy the following code and paste it into your website's HTML: