fix: 刮蜡独立拦截+安全权限化、新增PVP观察者观赛(/pvp see)、修复新布局PVP世界清理失效
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s

- 涂蜡/刮蜡:从 onInteract 拆出独立处理器 onWaxInteract(HIGHEST),改用 e.getItem()/getHand() 覆盖副手,
  同时 DENY useInteractedBlock+useItemInHand;denyWax 改为安全权限(不看上级破坏/放置开关、
  不豁免所有者/管理员/访客),并加放行原因诊断日志,解决"开了开关却拦不住"。
- PVP观赛:/pvp see 仅PVP世界生效,自动进原版观察者,回收PVP装备但不发还玩家自有装备,
  发放2个不同雪球右键在观察者⇄普通生存间切换;离场/断线/重连自动还原游戏模式。
- PVP世界清理:新增 resolveWorldFolder/listWorldFolders 兼容 1.20.4+ 新布局
  world/dimensions/minecraft/<name>,替换全部 getWorldContainer 拼路径,并加启动残留清扫,
  修复磁盘堆出20+个 pvp_arena_* 残留目录的问题。
This commit is contained in:
youpaishidifu
2026-09-27 18:56:22 +08:00
parent c65b64030f
commit d8ff4b0c92
4 changed files with 401 additions and 42 deletions
+80 -27
View File
@@ -2883,8 +2883,10 @@ public class AreaProtection implements Listener {
// 如果ac为null(不在任何领地内),直接允许
if (ac == null) return false;
// ★ denyPVP和denyAllDamage是全局安全设置,ADMIN/OWNER/VISITOR也不例外
boolean isSecurityPerm = "denyPVP".equals(permName) || "denyAllDamage".equals(permName);
// ★ denyPVP / denyAllDamage / denyWax 是全局安全设置,ADMIN/OWNER/VISITOR 也不例外
boolean isSecurityPerm = "denyPVP".equals(permName)
|| "denyAllDamage".equals(permName)
|| "denyWax".equals(permName);
PermissionLevel level = isSecurityPerm ? null : getPermissionLevel(player, ac);
// 1) ADMIN/OWNER 永远豁免
@@ -2926,14 +2928,14 @@ public class AreaProtection implements Listener {
/**
* ★ 涂蜡/刮蜡权限是否生效:
* 本权限为二级权限,上级权限=破坏方块/放置方块,
* 上级两项都未开启时,单独开启本项不生效。
* 2026-09-27 起改为【独立一级权限】:不再受"破坏方块/放置方块"上级开关约束,
* 也不做所有者/管理员/白名单豁免(按 denyPVP 同款"安全权限"处理)——
* 原因:此前"上级权限 + 角色豁免"双重条件导致领地开了开关却完全拦不住,
* 与"开启即禁止"的预期不符。需要放行特定玩家时,走 per-player 权限单独放行。
*/
private boolean isWaxDenied(Player player, AreaConfig ac) {
if (ac == null) return false;
if (!getEffectiveDeny(player, ac, "denyWax")) return false;
return getEffectiveDeny(player, ac, "denyBlockBreak")
|| getEffectiveDeny(player, ac, "denyBlockPlace");
return getEffectiveDeny(player, ac, "denyWax");
}
/** ★ 可涂蜡/刮蜡的方块(铜质方块、避雷针、告示牌) */
@@ -5288,6 +5290,72 @@ public class AreaProtection implements Listener {
}
}
// ===== 涂蜡/刮蜡权限(denyWax)—— 独立处理器 =====
// 为什么单独开一个方法:
// ① onInteract 开头有选区工具等分支会提前 return,合并写会被短路跳过;
// ② 用 e.getItem()/e.getHand() 取"实际使用的那只手",副手蜜脾/副手斧同样拦截;
// ③ 同时 DENY useInteractedBlock + useItemInHand,确保原版"刮蜡/涂蜡"流程(1.20.5+ 在
// ServerPlayerGameMode.useItemOn 中先查 useItemInHand 再继续 item.useOn)被彻底切断;
// ④ HIGHEST 优先级,避免被其它监听器的默认处理覆盖。
@EventHandler(priority = EventPriority.HIGHEST)
public void onWaxInteract(PlayerInteractEvent e) {
Action act = e.getAction();
if (act != Action.RIGHT_CLICK_BLOCK && act != Action.RIGHT_CLICK_AIR) return;
// 副手事件会与主手重复触发一次,只处理主手
if (e.getHand() == org.bukkit.inventory.EquipmentSlot.OFF_HAND) return;
Player p = e.getPlayer();
ItemStack used = e.getItem();
if (used == null || used.getType().isAir()) {
used = p.getInventory().getItemInMainHand();
}
Material mat = used.getType();
boolean waxOn = mat == Material.HONEYCOMB;
boolean waxOff = mat.name().endsWith("_AXE"); // 木/石/铜/铁/金/钻/下界合金斧全部匹配
if (!waxOn && !waxOff) return;
Block waxBlock = e.getClickedBlock();
if (waxBlock == null) {
try { waxBlock = p.getTargetBlockExact(6); } catch (Exception ignored) {}
}
if (waxBlock == null || !isWaxableBlock(waxBlock)) return;
AreaConfig waxAc = getArea(waxBlock.getWorld().getName(),
waxBlock.getX(), waxBlock.getY(), waxBlock.getZ());
if (waxAc == null) return; // 不在任何领地内 → 不干预
if (isWaxDenied(p, waxAc)) {
e.setUseInteractedBlock(org.bukkit.event.Event.Result.DENY);
e.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
e.setCancelled(true);
p.sendMessage("§c§l[区域防护] §f禁止" + (waxOn ? "涂蜡" : "刮蜡"));
return;
}
// ★ 诊断日志:领地已开启 denyWax 却放行时,把放行原因打进控制台,
// 方便定位"为什么没拦住"(所有者/管理员/白名单访客豁免是最常见原因)。
if (waxAc.denyWax) {
plugin.getLogger().info("[防护] 放行" + (waxOn ? "涂蜡" : "刮蜡")
+ ": 玩家=" + p.getName() + " 领地=" + waxAc.name
+ " 原因=" + waxAllowReason(p, waxAc));
}
}
/** 诊断用:denyWax 已开却放行的原因(改为安全权限后,只剩 per-player 单独放行一条路径) */
private String waxAllowReason(Player p, AreaConfig ac) {
try {
int landId = getLandIdFromDb(ac.name);
if (landId > 0) {
Map<String, Boolean> perms = getPlayerPermMap(landId, p.getName());
if (perms != null && perms.containsKey("denyWax")
&& !Boolean.TRUE.equals(perms.get("denyWax"))) {
return "该玩家被单独放行(denyWax=false)";
}
}
} catch (Exception ignored) {}
return "未知(请反馈该日志)";
}
// 替换整个 onInteract 方法
@EventHandler
public void onInteract(PlayerInteractEvent e) {
@@ -5370,24 +5438,9 @@ public class AreaProtection implements Listener {
}
// ===== 涂蜡/刮蜡权限(denyWax)=====
// 上级权限=破坏方块/放置方块:两者都未开启时,单独开启本项不生效
if (e.getAction() == Action.RIGHT_CLICK_BLOCK) {
Block waxBlock = e.getClickedBlock();
if (waxBlock != null) {
Material waxMat = hand.getType();
boolean waxOn = waxMat == Material.HONEYCOMB && isWaxableBlock(waxBlock);
boolean waxOff = waxMat.name().endsWith("_AXE") && isWaxableBlock(waxBlock);
if (waxOn || waxOff) {
AreaConfig waxAc = getArea(waxBlock.getWorld().getName(),
waxBlock.getX(), waxBlock.getY(), waxBlock.getZ());
if (isWaxDenied(p, waxAc)) {
e.setCancelled(true);
p.sendMessage("§c§l[区域防护] §f禁止" + (waxOn ? "涂蜡" : "刮蜡"));
return;
}
}
}
}
// ★ 已拆成独立处理器 onWaxInteract(见本类 onInteract 方法之前):
// ① 本方法开头有多处提前 return(选区工具分支等),合并写会被短路跳过;
// ② 独立处理器改用 e.getItem()/e.getHand() 取"实际使用的手",副手蜜脾/斧同样拦截。
// ===== 生物蛋权限(denySpawnEgg)=====
// 上级权限=放置方块:放置未开启时,单独开启本项不生效
@@ -6168,7 +6221,7 @@ public class AreaProtection implements Listener {
Player p = e.getPlayer();
Entity entity = e.getRightClicked();
// ★ 蜜脾涂蜡实体(铜傀儡等):受涂蜡权限管控(上级=破坏方块/放置方块)
// ★ 蜜脾涂蜡实体(铜傀儡等):受涂蜡权限管控(独立安全权限,开启即禁止)
ItemStack waxHand = p.getInventory().getItemInMainHand();
if (waxHand != null && waxHand.getType() == Material.HONEYCOMB) {
Location waxLoc = entity.getLocation();
@@ -12181,7 +12234,7 @@ public class AreaProtection implements Listener {
public boolean denyAnimalFeeding = false;
// ★ 生物蛋(右键刷怪蛋生成实体)——上级权限:放置方块(放置未开启时单独开启本项不生效)
public boolean denySpawnEgg = false;
// ★ 涂蜡/刮蜡(蜜脾涂蜡、斧头刮蜡)——上级权限:破坏方块/放置方块(两者都未开启时单独开启本项不生效)
// ★ 涂蜡/刮蜡(蜜脾涂蜡、斧头刮蜡)——独立安全权限:开启即对所有人(含所有者/管理员/访客)禁止
public boolean denyWax = false;
// ★ 玩家攻击生物
public boolean denyMobAttack = false;
+313 -13
View File
@@ -305,6 +305,8 @@ public class PVPArenaManager implements Listener {
cancelPendingDeletion();
// ★ 启动时顺手清理历史回收站目录,避免磁盘无限增长
cleanupOldTrashWorlds();
// ★ 启动时清理历史残留的 pvp_arena* 世界目录(新布局 world/dimensions/minecraft/ 下的也一并清)
cleanupStalePVPArenaWorlds();
World w = Bukkit.getWorld(pvpWorldName);
if (w != null && !w.getPlayers().isEmpty()) {
plugin.getLogger().info("[PVP] 检查:PVP世界已有玩家在场,保持现状");
@@ -686,6 +688,85 @@ public class PVPArenaManager implements Listener {
/**
* 递归删除世界目录
*/
// ==================== 世界目录解析(兼容 1.20.4+ / 26.x 新存储布局) ====================
//
// MC 1.20.4 起多世界不再存放在 Bukkit.getWorldContainer()(服务器根目录),
// 而是存放在 <主世界目录>/dimensions/<命名空间>/<世界名>/(本服即 world/dimensions/minecraft/pvp_arena_xxx)。
// 旧实现一律 new File(Bukkit.getWorldContainer(), name) → exists() 恒 false →
// 删除/回收站/残留清理全部静默 no-op,磁盘上因此堆出 20+ 个 pvp_arena_* 残留目录。
// 以下方法统一按"已加载世界 → 根目录老布局 → 新布局 dimensions/<ns>/<名字>"兜底解析。
/** 世界根目录候选:服务器根目录 + 其下每个一级子目录(主世界目录) */
private static File[] worldRootCandidates() {
List<File> roots = new ArrayList<>();
File container = Bukkit.getWorldContainer();
if (container == null) return roots.toArray(new File[0]);
roots.add(container);
File[] kids = container.listFiles();
if (kids != null) {
for (File k : kids) {
if (k.isDirectory()) roots.add(k);
}
}
return roots.toArray(new File[0]);
}
/**
* 按世界名解析其磁盘目录。
* 找不到时返回"老布局"路径(<根目录>/<世界名>),由调用方用 exists() 判断。
*/
public static File resolveWorldFolder(String worldName) {
if (worldName == null || worldName.isEmpty()) return null;
World loaded = Bukkit.getWorld(worldName);
if (loaded != null) {
File f = loaded.getWorldFolder();
if (f != null && f.exists()) return f;
}
File container = Bukkit.getWorldContainer();
if (container == null) return null;
File direct = new File(container, worldName);
if (direct.exists()) return direct;
for (File root : worldRootCandidates()) {
File dims = new File(root, "dimensions");
File[] namespaces = dims.listFiles();
if (namespaces == null) continue;
for (File ns : namespaces) {
if (!ns.isDirectory()) continue;
File cand = new File(ns, worldName);
if (cand.exists()) return cand;
}
}
return direct;
}
/**
* 列出所有名字以 prefix 开头的世界目录(新旧布局都扫,按绝对路径去重)。
* 供清理类逻辑使用——旧实现只扫根目录,新布局下的残留永远扫不到。
*/
public static List<File> listWorldFolders(String prefix) {
Map<String, File> found = new LinkedHashMap<>();
for (File root : worldRootCandidates()) {
collectByPrefix(root, prefix, found);
File dims = new File(root, "dimensions");
File[] namespaces = dims.listFiles();
if (namespaces == null) continue;
for (File ns : namespaces) {
if (ns.isDirectory()) collectByPrefix(ns, prefix, found);
}
}
return new ArrayList<>(found.values());
}
private static void collectByPrefix(File dir, String prefix, Map<String, File> out) {
File[] kids = dir.listFiles();
if (kids == null) return;
for (File k : kids) {
if (k.isDirectory() && k.getName().startsWith(prefix)) {
out.put(k.getAbsolutePath(), k);
}
}
}
private void deleteWorldFolder(File folder) {
if (folder == null || !folder.exists()) return;
File[] children = folder.listFiles();
@@ -727,17 +808,37 @@ public class PVPArenaManager implements Listener {
* best-effort,失败不阻断主流程。
*/
private void cleanupOldTrashWorlds() {
File container = Bukkit.getWorldContainer();
if (container == null || !container.isDirectory()) return;
File[] list = container.listFiles();
if (list == null) return;
for (File f : list) {
if (f.isDirectory() && f.getName().startsWith(PVP_WORLD_NAME + "_") && f.getName().contains("_trash_")) {
for (File f : listWorldFolders(PVP_WORLD_NAME + "_")) {
if (f.getName().contains("_trash_")) {
deleteWorldFolder(f);
}
}
}
/**
* ★ 启动时清理历史残留的 PVP 世界目录(含新布局 world/dimensions/minecraft/ 下的)。
* 旧实现只扫服务器根目录,1.20.4+ 新布局根本扫不到 → 磁盘上堆出 20+ 个 pvp_arena_* 目录。
* 规则:凡是【未被加载】的 pvp_arena* 目录都视为残留 → 移入回收站(Windows 删除锁友好)。
* world/dimensions/minecraft/ 下的 pvp_arena_* 全部属于一次性随机命名世界,删掉不影响任何在局对局。
*/
private void cleanupStalePVPArenaWorlds() {
int n = 0;
for (File f : listWorldFolders(PVP_WORLD_NAME)) {
String name = f.getName();
if (name.contains("_trash_")) continue; // 回收站交给 cleanupOldTrashWorlds()
if (Bukkit.getWorld(name) != null) continue; // 世界已加载 → 正在使用,跳过
plugin.getLogger().info("[PVP] 清理残留PVP世界目录: " + f.getAbsolutePath());
// 启动阶段这些世界都未加载(上面已跳过已加载的),直接递归删除即可立即释放磁盘;
// 若个别文件被占用删不掉,退回重命名移入回收站
deleteWorldFolder(f);
if (f.exists()) moveWorldToTrash(f);
n++;
}
if (n > 0) {
plugin.getLogger().info("[PVP] 共清理 " + n + " 个残留PVP世界目录(历史清理失效遗留)");
}
}
/**
* 取消待执行的"空场冷却删除世界"任务(有人重新进入时调用)
*/
@@ -782,9 +883,15 @@ public class PVPArenaManager implements Listener {
p.teleport(getBedOrSpawnLocation(p));
p.sendMessage("§e[PVP] 竞技场已关闭,你被传回主世界");
}
// ★ 卸载前先取真实世界目录:unload 后 Bukkit.getWorld() 返回 null,
// 而新布局下根目录根本不存在该文件夹(旧实现取根目录路径 → exists() 恒 false → 从不删除)
File worldFolder = world.getWorldFolder();
Bukkit.unloadWorld(world, false); // false=不保存(即将删除,避免残留破坏的地形)
// ★ 同样用重命名移走,避免 Windows 删除锁导致旧世界残留
moveWorldToTrash(new File(Bukkit.getWorldContainer(), pvpWorldName));
if (worldFolder == null || !worldFolder.exists()) {
worldFolder = resolveWorldFolder(pvpWorldName);
}
moveWorldToTrash(worldFolder);
// ★ 世界销毁 → 重置本局公平锁定,下一局首位玩家重新定死附魔
resetMatchLock();
plugin.getLogger().info("[PVP] PVP世界已删除,下次进入将随机重新生成地形");
@@ -803,9 +910,10 @@ public class PVPArenaManager implements Listener {
*/
private void deleteWorldFolderIfExists(String name) {
if (name == null) return;
File folder = new File(Bukkit.getWorldContainer(), name);
if (!folder.exists()) return;
plugin.getLogger().info("[PVP] best-effort 清理上一次世界目录: " + name);
File folder = resolveWorldFolder(name);
if (folder == null || !folder.exists()) return;
plugin.getLogger().info("[PVP] best-effort 清理上一次世界目录: "
+ folder.getAbsolutePath());
moveWorldToTrash(folder);
}
@@ -1123,6 +1231,13 @@ public class PVPArenaManager implements Listener {
plugin.getLogger().info("[PVP] 玩家 " + playerName + " 在PVP中断线,备份已存DB待还原");
}
// ★ 还原观赛前的游戏模式(避免把观察者模式一路带到主世界)
GameMode backMode = spectatorBackups.remove(playerName);
if (backMode != null && !isDisconnect && player.isOnline()
&& player.getGameMode() == GameMode.SPECTATOR) {
player.setGameMode(backMode == GameMode.SPECTATOR ? GameMode.SURVIVAL : backMode);
}
// 清理所有状态(含取消超时定时器)
inPVPArena.remove(playerName);
equipmentConfirmed.remove(playerName);
@@ -1533,6 +1648,12 @@ public class PVPArenaManager implements Listener {
db.deletePvpInventoryBackup(player.getName());
player.sendMessage("§a§l检测到你上次在PVP中断线,背包已自动恢复");
// ★ 上次以观赛观察者身份断线 → 重连强制恢复为生存(避免在主世界仍是观察者)
if (player.getGameMode() == GameMode.SPECTATOR) {
player.setGameMode(GameMode.SURVIVAL);
player.sendMessage("§a§l[PVP观赛] §f已退出观赛观察者模式");
}
// 若仍在竞技场世界,送回主世界
if (player.getWorld().getName().equals(pvpWorldName)) {
player.teleport(getBedOrSpawnLocation(player));
@@ -1551,6 +1672,10 @@ public class PVPArenaManager implements Listener {
player.teleport(getBedOrSpawnLocation(player));
cleanupPlayerStats(player);
inPVPArena.remove(player.getName());
// ★ 观赛观察者断线残留 → 强制恢复为生存
if (player.getGameMode() == GameMode.SPECTATOR) {
player.setGameMode(GameMode.SURVIVAL);
}
player.sendMessage("§e[PVP] 检测到你在竞技场非正常断线,已送你回主世界");
}, 5L);
}
@@ -2564,9 +2689,10 @@ public class PVPArenaManager implements Listener {
plugin.getLogger().info("[PVP] PVP世界当前无人,主线程卸载旧世界并重新生成全新主世界");
deletePVPWorld(pvpWorld);
} else {
File folder = new File(Bukkit.getWorldContainer(), pvpWorldName);
if (folder.exists()) {
plugin.getLogger().info("[PVP] 检测到磁盘残留PVP世界目录(无人在场),移入回收站后重新生成");
File folder = resolveWorldFolder(pvpWorldName);
if (folder != null && folder.exists()) {
plugin.getLogger().info("[PVP] 检测到磁盘残留PVP世界目录(无人在场),移入回收站后重新生成: "
+ folder.getAbsolutePath());
moveWorldToTrash(folder);
}
}
@@ -2649,6 +2775,179 @@ public class PVPArenaManager implements Listener {
player.sendMessage("§a§l正在离开PVP竞技场...");
}
// ==================== PVP 观赛(/pvp see 观察者模式)====================
/** 观赛雪球 lore 标记(与菜单雪球标签 sdf1_menu 区分,避免被误判为菜单物品) */
private static final String SPECTATE_TAG = "PVP观赛|";
/** 玩家名 → 进入观察者前的游戏模式(离场/重连时还原,避免把观察者模式带到主世界) */
private final Map<String, GameMode> spectatorBackups = new ConcurrentHashMap<>();
/** 是否 PVP 世界(pvp_arena* 竞技场 / pvp_test 测试场)——观赛功能仅在这些世界生效 */
public boolean isPVPWorld(World w) {
if (w == null) return false;
return w.getName().startsWith("pvp_");
}
/**
* /pvp see —— PVP 观赛模式(仅 PVP 世界生效)
* ① 已在观察者 → 切回普通生存模式
* ② 否则:回收 PVP 装备(★ 不发还玩家自己的装备,自己的装备仍在备份里、离场时统一还原)
* → 发放 2 个不同的雪球 → 自动进入 MC 原版观察者模式
*/
public void handleSeeCommand(Player p) {
if (!isPVPWorld(p.getWorld())) {
p.sendMessage("§c§l[PVP观赛] §f仅在PVP世界可用(/pvp join 进入竞技场后再使用)");
return;
}
if (p.getGameMode() == GameMode.SPECTATOR) {
exitSpectator(p);
return;
}
enterSpectator(p);
}
/** 进入观察者观赛:回收PVP装备(不退自己装备) → 发2个雪球 → 进入原版观察者 */
private void enterSpectator(Player p) {
if (p.getGameMode() == GameMode.SPECTATOR) return;
// 1) 回收PVP专属装备;★ 玩家自己的装备【不发还】,仍留在备份中,离场时统一还原
回收PVPEquipment(p);
// 2) 发放2个不同的雪球(必须在进入观察者之前发放:观察者收不到 /give,会掉在地上)
giveSpectatorSnowballs(p);
// 3) 记录原模式并进入 MC 原版观察者
spectatorBackups.put(p.getName(), p.getGameMode());
p.setGameMode(GameMode.SPECTATOR);
p.sendMessage("§a§l[PVP观赛] §f已进入观察者模式,可穿墙自由观赛");
p.sendMessage("§7 §b右键「观赛·观察者视角」§7→ 原版观察者(穿墙飞行)");
p.sendMessage("§7 §a右键「观赛·生存视角」§7→ 普通生存(落到地面观赛)");
p.sendMessage("§7 也可再次输入 §f/pvp see §7在两种模式间切换");
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 进入观赛观察者模式");
}
/** 退出观察者 → 还原进入前的游戏模式(默认生存),含安全落点与缓降保护 */
private void exitSpectator(Player p) {
if (p.getGameMode() != GameMode.SPECTATOR) return;
GameMode back = spectatorBackups.remove(p.getName());
if (back == null || back == GameMode.SPECTATOR) back = GameMode.SURVIVAL;
// 观察者常悬在高空或钻进方块里 → 切回可玩模式前先挪到可站立的空气格
safeRecoverPosition(p);
// 先给缓降再切模式,落地不摔死
p.addPotionEffect(new org.bukkit.potion.PotionEffect(
org.bukkit.potion.PotionEffectType.SLOW_FALLING, 20 * 8, 0, false, true));
p.setGameMode(back);
p.sendMessage("§a§l[PVP观赛] §f已退出观察者,恢复为" + gameModeName(back)
+ "模式(8秒缓降,地面观赛可能被战斗波及)");
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 退出观赛观察者模式 → " + back.name());
}
/** 观察者常卡在方块内部/高空 → 切回可玩模式前,把玩家挪到最近的可站立空气格 */
private void safeRecoverPosition(Player p) {
Location loc = p.getLocation();
World w = loc.getWorld();
if (w == null) return;
int x = loc.getBlockX(), y = loc.getBlockY(), z = loc.getBlockZ();
if (w.getBlockAt(x, y, z).isPassable()) return;
for (int dy = 1; dy <= 8; dy++) {
Block up = w.getBlockAt(x, y + dy, z);
if (up.isPassable()) {
p.teleport(new Location(w, x + 0.5, y + dy, z + 0.5, loc.getYaw(), loc.getPitch()));
return;
}
}
}
private String gameModeName(GameMode gm) {
switch (gm) {
case CREATIVE: return "创造";
case ADVENTURE: return "冒险";
case SPECTATOR: return "观察者";
default: return "生存";
}
}
/** 发放2个不同的雪球:观察者视角 / 生存视角(右键触发切换) */
private void giveSpectatorSnowballs(Player p) {
ItemStack spec = makeSpectateSnowball("§b观赛·观察者视角",
"§7右键 → 切换到 MC 原版观察者模式", "观察者");
ItemStack surv = makeSpectateSnowball("§a观赛·生存视角",
"§7右键 → 切换到普通生存模式(落地观赛)", "生存");
Map<Integer, ItemStack> left = p.getInventory().addItem(spec, surv);
if (left != null && !left.isEmpty()) {
for (ItemStack it : left.values()) {
p.getWorld().dropItemNaturally(p.getLocation(), it);
}
}
}
private ItemStack makeSpectateSnowball(String name, String desc, String key) {
ItemStack it = new ItemStack(Material.SNOWBALL);
ItemMeta im = it.getItemMeta();
if (im != null) {
im.setDisplayName(name);
im.setLore(Arrays.asList(desc, "§8[" + SPECTATE_TAG + key + "]"));
it.setItemMeta(im);
}
return it;
}
/** 判定雪球类型:0=非观赛雪球 1=观察者视角 2=生存视角 */
private int spectateSnowballType(ItemStack item) {
if (item == null || item.getType() != Material.SNOWBALL) return 0;
ItemMeta im = item.getItemMeta();
if (im == null || !im.hasLore()) return 0;
List<String> lore = im.getLore();
if (lore == null) return 0;
for (String line : lore) {
if (line == null || !line.contains(SPECTATE_TAG)) continue;
if (line.contains("观察者")) return 1;
if (line.contains("生存")) return 2;
}
return 0;
}
/**
* 观赛雪球:右键切换 观察者 ⇄ 生存(仅PVP世界生效)。
* ★ 必须 setCancelled(true),否则雪球会被当成投掷物消耗掉。
* ★ 原版限制:观察者模式下客户端只在【对着方块右键】时才发包,对空气右键不发包;
* 若当时视线没对准方块,可改用 /pvp see 切换(两种方式等价)。
*/
@EventHandler
public void onSpectateSnowball(PlayerInteractEvent e) {
Action a = e.getAction();
if (a != Action.RIGHT_CLICK_AIR && a != Action.RIGHT_CLICK_BLOCK) return;
if (e.getHand() == org.bukkit.inventory.EquipmentSlot.OFF_HAND) return;
Player p = e.getPlayer();
if (!isPVPWorld(p.getWorld())) return;
int type = spectateSnowballType(e.getItem());
if (type == 0) return;
e.setCancelled(true); // 阻止雪球被投掷消耗
if (type == 1) { // 观察者视角
if (p.getGameMode() == GameMode.SPECTATOR) {
p.sendMessage("§e[PVP观赛] 你已经在观察者模式了");
return;
}
enterSpectator(p);
} else { // 生存视角
if (p.getGameMode() != GameMode.SPECTATOR) {
p.sendMessage("§e[PVP观赛] 你已经在生存模式了");
return;
}
exitSpectator(p);
}
}
// ==================== 公共查询 ====================
public boolean isInPVPArena(String playerName) {
@@ -2728,6 +3027,7 @@ public class PVPArenaManager implements Listener {
inPVPArena.clear();
equipmentConfirmed.clear();
inventoryBackups.clear();
spectatorBackups.clear();
guiReopening.clear();
guiOpenedMillis.clear();
resetMatchLock();
+5
View File
@@ -1133,6 +1133,10 @@ public class PVPManager implements Listener {
}
plugin.getPVPArenaManager().openEquipmentSelection(p);
return true;
case "see":
// PVP观赛:进入/退出观察者模式(仅PVP世界生效,右键雪球可随时切换)
plugin.getPVPArenaManager().handleSeeCommand(p);
return true;
case "leave":
case "exit":
plugin.leavePVP(p);
@@ -1142,6 +1146,7 @@ public class PVPManager implements Listener {
p.sendMessage("§7——— PVP指令 ———");
p.sendMessage("§e/pvp join §7进入PVP竞技场");
p.sendMessage("§e/pvp equip §7调整装备(食物/盾牌/弓弩)");
p.sendMessage("§e/pvp see §7观赛:进入/退出观察者模式(仅PVP世界)");
p.sendMessage("§e/pvp leave §7离开PVP竞技场");
p.sendMessage("§e/pvp stats [玩家] §7查看战绩");
p.sendMessage("§e/pvp list §7查看PVP区域列表");
+3 -2
View File
@@ -159,8 +159,9 @@ public class PVPTestManager implements Listener {
* FLAT 世界生成极快,不会卡服。
*/
public void ensurePVPTestWorldExists() {
File folder = new File(Bukkit.getWorldContainer(), PVP_TEST_WORLD_NAME);
boolean fresh = !folder.exists();
// ★ 兼容 1.20.4+ 新存储布局(world/dimensions/minecraft/<名字>),旧的根目录拼接会恒定 exists()==false
File folder = PVPArenaManager.resolveWorldFolder(PVP_TEST_WORLD_NAME);
boolean fresh = folder == null || !folder.exists();
World w = Bukkit.getWorld(PVP_TEST_WORLD_NAME);
if (w == null) {