feat: /pvp see 重构为裁判⇄幽灵双形态切换(不再切回无保护普通玩家)、/pvp leave see 退出观赛时重新选装并补发PVP装备、断线重连补裁判身份清理
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s

This commit is contained in:
youpaishidifu
2026-09-28 12:39:10 +08:00
parent adae149791
commit 002b0e47a9
2 changed files with 149 additions and 82 deletions
+143 -76
View File
@@ -1917,12 +1917,15 @@ public class PVPArenaManager implements Listener {
db.deletePvpInventoryBackup(player.getName());
player.sendMessage("§a§l检测到你上次在PVP中断线,背包已自动恢复");
// ★ 上次以观赛观察者身份断线 → 重连强制恢复为生存(避免在主世界仍是观察者)
// ★ 上次以观赛身份断线(幽灵/裁判任一形态)→ 重连统一结束观赛,避免身份残留:
// 幽灵强制回生存;裁判本就是生存,仅注销身份(背包已还原、即将送回主世界)
if (player.getGameMode() == GameMode.SPECTATOR) {
// ★ 先注销观赛身份再改模式:否则留着身份残留,会被战斗拦截/强制巡视误伤
spectatorBackups.remove(player.getName());
player.setGameMode(GameMode.SURVIVAL);
player.sendMessage("§a§l[PVP观赛] §f已退出观赛观察者模式");
player.sendMessage("§a§l[PVP观赛] §f已退出观赛(幽灵形态已恢复生存)");
} else if (spectatorBackups.remove(player.getName()) != null) {
player.sendMessage("§a§l[PVP观赛] §f已退出观赛(裁判身份已注销)");
}
// 若仍在竞技场世界,送回主世界
@@ -1943,10 +1946,12 @@ public class PVPArenaManager implements Listener {
player.teleport(getBedOrSpawnLocation(player));
cleanupPlayerStats(player);
inPVPArena.remove(player.getName());
// ★ 观赛观察者断线残留 → 强制恢复为生存(先注销观赛身份,避免身份残留)
// ★ 观赛断线残留(幽灵/裁判任一形态)→ 统一结束观赛(先注销身份,避免身份残留)
if (player.getGameMode() == GameMode.SPECTATOR) {
spectatorBackups.remove(player.getName());
player.setGameMode(GameMode.SURVIVAL);
} else {
spectatorBackups.remove(player.getName());
}
player.sendMessage("§e[PVP] 检测到你在竞技场非正常断线,已送你回主世界");
}, 5L);
@@ -3072,39 +3077,56 @@ public class PVPArenaManager implements Listener {
* /pvp see —— PVP 观赛模式(仅 PVP 世界生效)
*
* 三种调用形式:
* /pvp see → 切换(在观察者⇄普通模式之间来回切,mode=null)
* /pvp join see → 强制进入观察者(mode="enter")
* /pvp leave see → 强制退出观察者(mode="exit")
* /pvp see → 切换观赛形态(mode=null)
* /pvp join see → 强制进入观赛(mode="enter",默认幽灵形态)
* /pvp leave see → 彻底退出观赛(mode="exit",并重新触发选装、补发PVP装备)
*
* ★ v2 设计变更:原版【观察者模式下物品栏会被客户端自动隐藏替换】,
* 雪球发了也看不见、无法右键,故已彻底移除雪球方案,只保留命令切换。
* 观察者不能伤害玩家,玩家也不能伤害观察者(见 onSpectatorCombat)。
* ★ v3 形态设计(2026-09-28 修正):观赛有两种形态,/pvp see 只在二者之间来回切:
* ① 幽灵形态 = 原版观察者模式:能看到别人,别人看不到你;
* ② 裁判形态 = 普通生存模式 + 已登记观赛身份:可见站在现场像裁判,
* 但不能伤人、别人也不能伤害你(双向免伤,见 onSpectatorCombat)。
* ★ 切换【绝不】把玩家变成无保护的普通玩家;只有 /pvp leave see 才彻底退出,
* 且退出时重新触发选装、补发装备,避免"退出观赛后没装备打不过"。
*/
public void handleSeeCommand(Player p) {
handleSeeCommand(p, null);
}
/**
* @param mode null=切换 "enter"=强制进观察者 "exit"=强制退观察者
* @param mode null=切换形态 "enter"=强制进入观赛(幽灵) "exit"=彻底退出观赛(补发装备)
*/
public void handleSeeCommand(Player p, String mode) {
boolean already = p.getGameMode() == GameMode.SPECTATOR;
boolean ghost = p.getGameMode() == GameMode.SPECTATOR;
boolean registered = spectatorBackups.containsKey(p.getName());
boolean spectating = ghost || registered; // 幽灵/裁判任一形态都算观赛者
// ★ 退出观察者在任何世界都放行(防止玩家被带离PVP世界后卡在观察者回不来)
// ★ /pvp leave see → 彻底退出观赛:注销身份 + 还原模式 + 重新触发选装补发装备
// (任何世界都放行,防止玩家被带离PVP世界后卡在观赛身份回不来)
if ("exit".equals(mode)) {
if (!already) {
// ★ 状态漂移兜底:身份仍登记为观赛者但模式已被外部改走 → 清掉身份残留
if (spectatorBackups.remove(p.getName()) != null) {
p.sendMessage("§e§l[PVP观赛] §f你已不在观察者模式,已清理观赛身份残留");
plugin.getLogger().info("[PVP观赛] 玩家 " + p.getName()
+ " 用 /pvp leave see 清理了观赛身份残留(模式已是 "
+ p.getGameMode().name() + ")");
return;
}
p.sendMessage("§e§l[PVP观赛] §f你当前不在观察者模式");
if (!spectating) {
p.sendMessage("§e§l[PVP观赛] §f你当前不在观赛状态");
return;
}
// ★ 先回收可能残留的PVP专属装备,防止接下来补发时装备翻倍
回收PVPEquipment(p);
exitSpectator(p);
// ★ 重新触发PVP选装补发装备(仅限仍在PVP世界的参战者;
// 已被带离竞技场的玩家由离场流程还原自己的背包,不发PVP装备)
if (isPVPWorld(p.getWorld())) {
p.sendMessage("§e§l[PVP观赛] §f正在重新打开选装界面并补发你的PVP装备");
Bukkit.getScheduler().runTask(plugin, () -> {
if (!p.isOnline() || !isPVPWorld(p.getWorld())) return;
if (p.getGameMode() == GameMode.SPECTATOR
|| spectatorBackups.containsKey(p.getName())) return; // 已再次进入观赛
openEquipmentSelection(p);
// 未完成过选装的玩家重新计时(60秒未确认自动遣返的安全网继续生效)
if (!equipmentConfirmed.contains(p.getName()) && inPVPArena.contains(p.getName())) {
scheduleKickTimeout(p.getName());
}
plugin.getLogger().info("[PVP观赛] 玩家 " + p.getName()
+ " 彻底退出观赛,已重新触发PVP选装补发装备");
});
}
return;
}
@@ -3114,31 +3136,38 @@ public class PVPArenaManager implements Listener {
}
if ("enter".equals(mode)) {
if (already) {
p.sendMessage("§e§l[PVP观赛] §f你已经在观察者模式了");
if (spectating) {
p.sendMessage(ghost
? "§e§l[PVP观赛] §f你已经在观赛中(幽灵形态,别人看不到你)"
: "§e§l[PVP观赛] §f你已经在观赛中(裁判形态,§7/pvp see §f可切幽灵)");
return;
}
enterSpectator(p);
return;
}
// mode == null → 切换
if (already) {
exitSpectator(p);
} else {
// mode == null → 在两种观赛形态之间切换(普通玩家首次按 → 先进幽灵形态)
if (!spectating) {
enterSpectator(p);
} else if (ghost) {
toReferee(p);
} else {
toGhost(p);
}
}
/** 进入观察者观赛:回收PVP装备(不退自己装备) → 记录原模式 → 进入原版观察者 */
/** 进入观赛(默认幽灵形态):回收PVP装备(不退自己装备) → 记录原模式 → 进入原版观察者 */
private void enterSpectator(Player p) {
if (p.getGameMode() == GameMode.SPECTATOR) return;
// 1) 回收PVP专属装备;★ 玩家自己的装备【不发还】,仍留在备份中,离场时统一还原
回收PVPEquipment(p);
// 2) 记录原模式并进入 MC 原版观察者
// ★ 必须【先记再切】:观察者模式下 addItem 会掉地上,故不再发放任何物品
// ★ 观赛期间没有"选装中"这回事 → 暂停60秒未确认遣返定时器(退出观赛时重新计时)
cancelKickTimeout(p.getName());
// 2) 记录原模式并进入 MC 原版观察者(幽灵形态)
// ★ 必须【先记再切】:观赛身份登记是战斗保护的唯一依据
// ★ 绝不把【创造模式】当作还原目标:以创造进入观赛的,退出一律回生存(防止退出观赛变成创造作弊)
GameMode before = p.getGameMode();
if (before == GameMode.CREATIVE || before == GameMode.SPECTATOR) {
@@ -3147,46 +3176,72 @@ public class PVPArenaManager implements Listener {
spectatorBackups.put(p.getName(), before);
p.setGameMode(GameMode.SPECTATOR);
p.sendMessage("§a§l[PVP观赛] §f已进入观察者模式,可穿墙自由观赛");
p.sendMessage("§7 §f/pvp see §7→ 切换回普通模式");
p.sendMessage("§7 §f/pvp leave see §7→ 退出观察者");
p.sendMessage("§7 旁观状态下你与他人互不伤害,可放心观战");
p.sendMessage("§a§l[PVP观赛] §f已进入【幽灵形态】:能看到别人,别人看不到你");
p.sendMessage("§7 §f/pvp see §7→ 切换为裁判形态(可见站在现场,同样互不伤害)");
p.sendMessage("§7 §f/pvp leave see §7→ 彻底退出观赛(重新选装并补发PVP装备)");
p.sendMessage("§7 观赛期间你与他人互不伤害,可放心观战");
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 进入观赛观察者模式");
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 进入观赛(幽灵形态)");
}
/** 退出观察者 → 还原进入前的游戏模式(默认生存),含安全落点与缓降保护 */
private void exitSpectator(Player p) {
GameMode back = spectatorBackups.remove(p.getName());
/**
* 幽灵形态 → 裁判形态:原版观察者切回普通生存模式,但【保留观赛身份登记】。
* 裁判可见站在现场,却依旧双向免伤——保护依据是登记身份而不是游戏模式(见 isProtectedSpectator)。
*/
private void toReferee(Player p) {
// 兜底:没走过 enterSpectator 的野生观察者 → 补登记,否则切回生存后彻底没人保护
spectatorBackups.putIfAbsent(p.getName(), GameMode.SURVIVAL);
// ★ 状态漂移兜底:身份登记为观赛者但模式已被外部改走(领地强制模式/其他插件等)
// → 只清掉观赛身份即可,不再动模式(避免把别人的设置覆盖掉)
if (p.getGameMode() != GameMode.SPECTATOR) {
if (back != null) {
plugin.getLogger().info("[PVP观赛] 玩家 " + p.getName() + " 退出观赛"
+ "(模式已被外部改为 " + p.getGameMode().name() + ",仅清理观赛身份)");
p.sendMessage("§a§l[PVP观赛] §f已退出观赛身份");
}
return;
}
// 幽灵常悬在高空或钻进方块里 → 落地安全处理(同退出流程:安全落点 + 缓降)
safeRecoverPosition(p);
p.addPotionEffect(new org.bukkit.potion.PotionEffect(
org.bukkit.potion.PotionEffectType.SLOW_FALLING, 20 * 8, 0, false, true));
p.setGameMode(GameMode.SURVIVAL);
p.sendMessage("§a§l[PVP观赛] §f已切换为【裁判形态】:可见站在现场,你与他人互不伤害");
p.sendMessage("§7 §f/pvp see §7→ 切换为幽灵形态(别人看不到你)");
p.sendMessage("§7 §f/pvp leave see §7→ 彻底退出观赛(重新选装并补发PVP装备)");
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 观赛切换 → 裁判形态(生存+登记保护)");
}
/** 裁判形态 → 幽灵形态:切回原版观察者(保留登记;期间持有的物品随身带进幽灵) */
private void toGhost(Player p) {
if (p.getGameMode() == GameMode.SPECTATOR) return;
p.setGameMode(GameMode.SPECTATOR);
p.sendMessage("§a§l[PVP观赛] §f已切换为【幽灵形态】:能看到别人,别人看不到你");
p.sendMessage("§7 §f/pvp see §7→ 切换为裁判形态(可见站在现场)");
p.sendMessage("§7 §f/pvp leave see §7→ 彻底退出观赛(重新选装并补发PVP装备)");
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 观赛切换 → 幽灵形态");
}
/** 彻底退出观赛 → 注销身份并还原进入前的游戏模式(默认生存),含安全落点与缓降保护 */
private void exitSpectator(Player p) {
boolean wasGhost = p.getGameMode() == GameMode.SPECTATOR;
GameMode back = spectatorBackups.remove(p.getName());
// ★ 兜底:备份缺失/观察者/创造 一律还原为生存(退出观赛绝不回创造,防止作弊)
if (back == null || back == GameMode.SPECTATOR || back == GameMode.CREATIVE) {
back = GameMode.SURVIVAL;
}
// 观察者常悬在高空或钻进方块里 → 切回可玩模式前先挪到可站立的空气格
safeRecoverPosition(p);
// 先给缓降再切模式,落地不摔死
p.addPotionEffect(new org.bukkit.potion.PotionEffect(
org.bukkit.potion.PotionEffectType.SLOW_FALLING, 20 * 8, 0, false, true));
// 幽灵形态才需要落点/缓降恢复(裁判形态本来就站在地面上)
if (wasGhost) {
// 观察者常悬在高空或钻进方块里 → 切回可玩模式前先挪到可站立的空气格
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.sendMessage("§c§l⚠ 从现在起你是生存模式:§f可以被攻击、也会攻击到别人 §c(再输 /pvp join see 可重新观赛)");
p.sendMessage("§a§l[PVP观赛] §f已退出观赛,恢复为" + gameModeName(back) + "模式");
p.sendMessage("§c§l⚠ 你现在是普通参战者:§f可以被攻击、也会攻击到别人");
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f);
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 退出观赛观察者模式 → " + back.name());
plugin.getLogger().info("[PVP] 玩家 " + p.getName() + " 彻底退出观赛(原形态="
+ (wasGhost ? "幽灵" : "裁判") + ")→ " + back.name());
}
/** 观察者常卡在方块内部/高空 → 切回可玩模式前,把玩家挪到最近的可站立空气格 */
@@ -3224,11 +3279,12 @@ public class PVPArenaManager implements Listener {
private long spectatorRosterLogAt = 0L;
/**
* ★ 观赛身份判定(战斗保护唯一依据,2026-09-28 重构 / 同日二次加固):
* - 原版观察者模式(GameMode.SPECTATOR)→ 是观赛者(任何世界);
* - 已在 spectatorBackups 登记为观赛者 → 是观赛者,【不再限定 PVP 世界】
* (即使模式被领地强制模式/其他插件改成生存、甚至被带出竞技场也照样算,
* 杜绝"观赛者+生存"能互殴;所有正常退出流程都会先注销登记,不会误伤)。
* ★ 观赛身份判定(战斗保护唯一依据,2026-09-28 重构 / 同日二次加固 + 形态修正):
* 观赛有两种形态,二者都受保护:
* - 幽灵形态 = 原版观察者模式(GameMode.SPECTATOR)→ 是观赛者(任何世界);
* - 裁判形态 = 普通生存模式 + 已在 spectatorBackups 登记 → 是观赛者,
* 【不再限定 PVP 世界】(即使模式被领地强制模式/其他插件改成生存、
* 甚至被带出竞技场也照样算;所有正常退出流程都会先注销登记,不会误伤)。
* 只要判定为观赛者,攻与被攻一律拦截:不看世界、不看来源类型、不看伤害对象是不是玩家。
*/
private boolean isProtectedSpectator(Player p) {
@@ -3290,7 +3346,8 @@ public class PVPArenaManager implements Listener {
* 每2秒巡视(2026-09-28 二次加固):
* ① PVP世界里出现"观察者模式但没登记"的玩家 → 自动补登记,
* 堵住"不是走 enterSpectator 进的观察者 → 身份没登记 → 被改成生存后彻底没人保护"的漏洞;
* ② 已登记观赛者但模式不是观察者 → 强制恢复观察者(PVP世界内);
* ② 已登记观赛者在PVP世界内:生存/冒险=合法裁判形态(不纠偏);
* 创造等非法形态 → 强制回到裁判形态(生存);
* 在 PVP 世界之外则保留身份并告警(战斗照样拦截,避免把人硬锁在观察者里);
* ③ 每30秒打印一次观赛名册,用来确认"身份到底登记上没有"。
* 这是"观赛者以任何形式都不能攻击/被攻击"的第二道防线
@@ -3318,18 +3375,23 @@ public class PVPArenaManager implements Listener {
total++;
if (gm == GameMode.SPECTATOR) {
roster.append(p.getName()).append("(观察者@").append(p.getWorld().getName()).append(") ");
roster.append(p.getName()).append("(幽灵@").append(p.getWorld().getName()).append(") ");
continue;
}
if (isPVPWorld(p.getWorld())) {
// ② PVP世界内:强制恢复观察者
p.setGameMode(GameMode.SPECTATOR);
logSpectatorDrift("enforce:" + p.getName(),
"观赛者 " + p.getName() + " 在PVP世界内被外部改为 " + gm.name()
+ ",已强制恢复观察者(world=" + p.getWorld().getName()
+ ")。若反复出现请排查:领地强制游戏模式 / 其他插件的 setGameMode");
roster.append(p.getName()).append("(强制恢复观察者,原").append(gm.name()).append(") ");
if (gm == GameMode.SURVIVAL || gm == GameMode.ADVENTURE) {
// ② 裁判形态(生存/冒险 + 登记)= 合法形态:可见现场但双向免伤,不纠偏
roster.append(p.getName()).append("(裁判@").append(p.getWorld().getName()).append(") ");
} else {
// ② 异常形态(创造等):强制回到裁判形态(生存)——绝不把观赛者留在创造里
p.setGameMode(GameMode.SURVIVAL);
logSpectatorDrift("enforce:" + p.getName(),
"观赛者 " + p.getName() + " 在PVP世界内处于非法形态 " + gm.name()
+ ",已强制回到裁判形态(生存,world=" + p.getWorld().getName()
+ ")。若反复出现请排查:其他插件的 setGameMode");
roster.append(p.getName()).append("(强制裁判,原").append(gm.name()).append(") ");
}
} else {
// ② 出了PVP世界:只告警+保留保护,不硬锁观察者(正常退出会先注销身份)
logSpectatorDrift("outside:" + p.getName(),
@@ -3350,17 +3412,22 @@ public class PVPArenaManager implements Listener {
/**
* 观赛者模式被改动的诊断钩子(仅记录,不拦截——真正的纠偏交给 enforceSpectatorModes)。
* ★ v3 形态语义:幽灵⇄裁判切换(本插件 /pvp see 或外部改成生存/冒险)都是正常形态,
* 且登记保护不受游戏模式影响,故不告警;只有切到创造等非法形态才留痕
* (enforceSpectatorModes 会在2秒内强制拉回裁判形态)。
* 插件自身的正常退出(exitSpectator/forceLeaveArena/onPlayerJoin重置)都会【先移除登记】
* 再改模式,因此不会触发本日志;触发即说明是外部改动,可据此定位根因。
* 再改模式,因此不会触发本日志。
*/
@EventHandler(priority = org.bukkit.event.EventPriority.MONITOR, ignoreCancelled = true)
public void onSpectatorGameModeChanged(org.bukkit.event.player.PlayerGameModeChangeEvent e) {
Player p = e.getPlayer();
if (!spectatorBackups.containsKey(p.getName())) return;
if (e.getNewGameMode() == GameMode.SPECTATOR) return;
GameMode nm = e.getNewGameMode();
if (nm == GameMode.SPECTATOR) return; // 幽灵形态:合法
if (nm == GameMode.SURVIVAL || nm == GameMode.ADVENTURE) return; // 裁判形态:合法(登记保护仍生效)
logSpectatorDrift("change:" + p.getName(),
"观赛者 " + p.getName() + " 的模式被外部从观察者改为 " + e.getNewGameMode()
+ "(world=" + p.getWorld().getName() + ")→ 2秒内将强制恢复观察者");
"观赛者 " + p.getName() + " 的模式被外部改为 " + nm
+ "(world=" + p.getWorld().getName() + ")→ 2秒内将强制恢复裁判形态(生存)");
}
/**
+6 -6
View File
@@ -1123,7 +1123,7 @@ public class PVPManager implements Listener {
p, args);
case "join":
case "arena":
// ★ /pvp join see → 直接进入观察者观赛模式
// ★ /pvp join see → 直接进入观赛(默认幽灵形态)
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
plugin.getPVPArenaManager().handleSeeCommand(p, "enter");
return true;
@@ -1139,12 +1139,12 @@ public class PVPManager implements Listener {
plugin.getPVPArenaManager().openEquipmentSelection(p);
return true;
case "see":
// PVP观赛:/pvp see=切换观察者⇄普通(仅PVP世界,权限对所有人开放)
// PVP观赛:/pvp see=在裁判⇄幽灵两种观赛形态间切换(仅PVP世界,绝不切回无保护的普通玩家)
plugin.getPVPArenaManager().handleSeeCommand(p);
return true;
case "leave":
case "exit":
// ★ /pvp leave see → 退出观察者模式
// ★ /pvp leave see → 彻底退出观赛:恢复身份并重新触发选装、补发PVP装备
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
plugin.getPVPArenaManager().handleSeeCommand(p, "exit");
return true;
@@ -1156,9 +1156,9 @@ 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 join see §7加入观赛:进入观察者模式");
p.sendMessage("§e/pvp leave see §7退出观赛:离开观察者模式");
p.sendMessage("§e/pvp see §7观赛:切换裁判⇄幽灵形态(仅PVP世界)");
p.sendMessage("§e/pvp join see §7加入观赛:进入幽灵形态");
p.sendMessage("§e/pvp leave see §7退出观赛:重新选装并补发PVP装备");
p.sendMessage("§e/pvp leave §7离开PVP竞技场");
p.sendMessage("§e/pvp stats [玩家] §7查看战绩");
p.sendMessage("§e/pvp list §7查看PVP区域列表");