第35批:公告计分板(公告.md json块解码/排序位冲突自动进位/15字截断换行/链接可点击/颜色三形态)+PVP侧边栏共存+垃圾站播报加轮次信息
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import io.papermc.paper.scoreboard.numbers.NumberFormat;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||
import org.bukkit.scoreboard.Criteria;
|
||||
import org.bukkit.scoreboard.DisplaySlot;
|
||||
import org.bukkit.scoreboard.Objective;
|
||||
import org.bukkit.scoreboard.Score;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.bukkit.scoreboard.ScoreboardManager;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* ★ 第35批:公告计分板(侧边栏)。
|
||||
*
|
||||
* <p><b>数据源</b>:{@code plugins/Sdf1_login/公告/公告.md}
|
||||
* 头部 {@code ## ----公告区域开始----} ~ {@code ## ----公告区域结束----}
|
||||
* 之间的 ```json 代码块。块的键是<b>排序位</b>,值是公告条目。
|
||||
*
|
||||
* <p><b>规则</b>:
|
||||
* <ul>
|
||||
* <li>键 = 排序位(升序,越小越靠上);键冲突 → <b>自动进位</b>
|
||||
* (往后找第一个空位)。用 JsonReader 流式解析,
|
||||
* 重复键不会被 Gson 的 Map 覆盖掉</li>
|
||||
* <li>msg 为空 → 整条跳过(空白键值自动跳过)</li>
|
||||
* <li>msg 超过 15 个字符 → 截断换行(每行最多 15 字,超出的另起一行)</li>
|
||||
* <li>msg 是 http/https 链接 → 整条公告可点击(打开链接,悬停显示完整地址)</li>
|
||||
* <li>color 支持 16 色英文名 / #RRGGBB / r=0 g=0 b=0;
|
||||
* 未填或非法 → 回退 MC 原版白色</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>渲染口径(paper-api 26.2 + 服务端 jar 字节码实测)</b>:
|
||||
* <ul>
|
||||
* <li>原版侧边栏<b>不解析 entry 里的 § 颜色码</b>(entry 只是纯文本标识),
|
||||
* 所以颜色必须走 {@code Score.customName(Component)} 下发;
|
||||
* entry 改用纯 ASCII 唯一串 {@code n0/n1/...},天然规避同名塌缩。</li>
|
||||
* <li>侧边栏默认 <b>descending(高分在上)</b> → 分数<b>递减</b>赋值,
|
||||
* 排序位小的排最上面。</li>
|
||||
* <li>侧边栏<b>最多只显示 15 行</b> → 超出部分丢弃并告警。</li>
|
||||
* <li>数字格式设 {@code NumberFormat.blank()} → 右侧不露分数。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>与 PVP 计分板共存</b>:PVP 区域 / 竞技场内由 PVP 模块接管计分板,
|
||||
* 本类只在玩家空闲(不在 PVP 区域、不在竞技场)时把公告板挂上去。
|
||||
*/
|
||||
public class AnnouncementBoard implements Listener {
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
/** 公告配置文件:plugins/Sdf1_login/公告/公告.md */
|
||||
private File mdFile;
|
||||
|
||||
/** 上次解析时的文件修改时间(用于热重载检测) */
|
||||
private long lastModified = 0L;
|
||||
|
||||
/** 公告区域标记 */
|
||||
private static final String ZONE_START = "## ----公告区域开始----";
|
||||
private static final String ZONE_END = "## ----公告区域结束----";
|
||||
|
||||
/** 计分板目标名(objective 名) */
|
||||
private static final String OBJECTIVE = "sdf1_notice";
|
||||
|
||||
/** 单行最大字符数(超出截断换行) */
|
||||
static final int MAX_LINE_CHARS = 15;
|
||||
|
||||
/** 原版侧边栏最多显示的行数(硬限制) */
|
||||
static final int MAX_SIDEBAR_ROWS = 15;
|
||||
|
||||
/** 公告板标题 */
|
||||
private static final String TITLE = "§6§l服务器公告";
|
||||
|
||||
/** 16 色英文名(与 JsonMessageGui 口径一致) */
|
||||
private static final Set<String> NATIVE_COLORS = new LinkedHashSet<>(Arrays.asList(
|
||||
"black", "dark_blue", "dark_green", "dark_aqua",
|
||||
"dark_red", "dark_purple", "gold", "gray",
|
||||
"dark_gray", "blue", "green", "aqua",
|
||||
"red", "light_purple", "yellow", "white"));
|
||||
|
||||
/** 链接识别(整条 msg 是链接才算) */
|
||||
private static final Pattern URL_PATTERN =
|
||||
Pattern.compile("^(https?://\\S+)$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/** 解析出的公告条目(排序位 → 条目),按排序位升序 */
|
||||
private final Map<Integer, Notice> notices = new LinkedHashMap<>();
|
||||
|
||||
/** 单条公告 */
|
||||
public static final class Notice {
|
||||
/** 排序位 */
|
||||
public final int slot;
|
||||
/** 原始文本 */
|
||||
public final String msg;
|
||||
/** 归一化后的颜色(合法色 / null=白色) */
|
||||
public final TextColor color;
|
||||
/** 链接地址(非链接时 null) */
|
||||
public final String url;
|
||||
|
||||
Notice(int slot, String msg, TextColor color, String url) {
|
||||
this.slot = slot;
|
||||
this.msg = msg;
|
||||
this.color = color;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
public AnnouncementBoard(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
/** 初始化:释放模板 → 解析 → 首次渲染 → 启动热重载监听 */
|
||||
public void init() {
|
||||
try {
|
||||
File dir = new File(plugin.getDataFolder(), "公告");
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
mdFile = new File(dir, "公告.md");
|
||||
if (!mdFile.exists()) {
|
||||
try (InputStream in = plugin.getResource("公告/公告.md")) {
|
||||
if (in != null) {
|
||||
Files.copy(in, mdFile.toPath());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 模板释放失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 初始化失败: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
applyAll();
|
||||
startAutoReload();
|
||||
}
|
||||
|
||||
/** 启动文件热重载:每 30 秒比对一次修改时间 */
|
||||
private void startAutoReload() {
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (mdFile == null || !mdFile.exists()) return;
|
||||
long mt = mdFile.lastModified();
|
||||
if (mt == lastModified) return;
|
||||
lastModified = mt;
|
||||
reloadAndApply();
|
||||
plugin.getLogger().info("[公告板] 检测到公告文件变更,已自动重载");
|
||||
}, 600L, 600L);
|
||||
}
|
||||
|
||||
/** 重新解析公告.md */
|
||||
public void reload() {
|
||||
List<Notice> list = parse();
|
||||
notices.clear();
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
Notice n = list.get(i);
|
||||
notices.put(n.slot, n);
|
||||
}
|
||||
if (mdFile != null && mdFile.exists()) {
|
||||
lastModified = mdFile.lastModified();
|
||||
}
|
||||
plugin.getLogger().info("[公告板] 已加载 " + notices.size() + " 条公告");
|
||||
}
|
||||
|
||||
/** 重载并立即刷新所有玩家 */
|
||||
public void reloadAndApply() {
|
||||
reload();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
public File getMdFile() {
|
||||
return mdFile;
|
||||
}
|
||||
|
||||
public int getNoticeCount() {
|
||||
return notices.size();
|
||||
}
|
||||
|
||||
// ==================== 解析 ====================
|
||||
|
||||
/**
|
||||
* 解析公告.md 里的 JSON 块。
|
||||
*
|
||||
* <p>用 {@link JsonReader} 流式解析而非 {@code JsonObject}:
|
||||
* 后者会把重复键覆盖掉,「键冲突自动进位」就无从谈起。
|
||||
*
|
||||
* @return 按排序位升序的公告列表
|
||||
*/
|
||||
private List<Notice> parse() {
|
||||
List<Notice> out = new ArrayList<>();
|
||||
if (mdFile == null || !mdFile.exists()) return out;
|
||||
String text;
|
||||
try {
|
||||
text = new String(Files.readAllBytes(mdFile.toPath()), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 读取失败: " + e.getMessage());
|
||||
return out;
|
||||
}
|
||||
|
||||
// ★ 变量名避开 e/ex(否则遮蔽 catch 块里的 Exception e)
|
||||
int zStart = text.indexOf(ZONE_START);
|
||||
int zEnd = text.indexOf(ZONE_END);
|
||||
if (zStart < 0 || zEnd < 0 || zEnd <= zStart) {
|
||||
plugin.getLogger().warning("[公告板] 未找到公告区域标记("
|
||||
+ ZONE_START + " ~ " + ZONE_END + "),公告板为空");
|
||||
return out;
|
||||
}
|
||||
String zone = text.substring(zStart + ZONE_START.length(), zEnd);
|
||||
|
||||
String json = extractJsonBlock(zone);
|
||||
if (json == null || json.trim().isEmpty()) {
|
||||
plugin.getLogger().warning("[公告板] 公告区域内没有 json 代码块");
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- 流式解析:保留重复键 ----
|
||||
List<Notice> raw = new ArrayList<>();
|
||||
int autoSlot = 1000000; // 非数字键的兜底排序位(排在数字键之后)
|
||||
JsonReader reader = null;
|
||||
try {
|
||||
reader = new JsonReader(new StringReader(json));
|
||||
reader.setLenient(true);
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String key = reader.nextName();
|
||||
String msg = null;
|
||||
String colorRaw = null;
|
||||
if (reader.peek() == JsonToken.NULL) {
|
||||
reader.skipValue();
|
||||
} else {
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String vk = reader.nextName();
|
||||
if ("msg".equals(vk)) {
|
||||
if (reader.peek() == JsonToken.NULL) reader.skipValue();
|
||||
else msg = reader.nextString();
|
||||
} else if ("color".equals(vk)) {
|
||||
if (reader.peek() == JsonToken.NULL) reader.skipValue();
|
||||
else colorRaw = reader.nextString();
|
||||
} else {
|
||||
reader.skipValue();
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
}
|
||||
|
||||
if (msg == null) continue;
|
||||
msg = msg.trim();
|
||||
if (msg.isEmpty()) continue; // 空白键值自动跳过
|
||||
if (colorRaw == null) colorRaw = "";
|
||||
else colorRaw = colorRaw.trim();
|
||||
|
||||
int slot;
|
||||
try {
|
||||
slot = Integer.parseInt(key.trim());
|
||||
} catch (Exception ex) {
|
||||
slot = autoSlot++;
|
||||
}
|
||||
|
||||
TextColor color = parseColor(colorRaw);
|
||||
String url = null;
|
||||
Matcher m = URL_PATTERN.matcher(msg);
|
||||
if (m.matches()) url = m.group(1);
|
||||
|
||||
raw.add(new Notice(slot, msg, color, url));
|
||||
}
|
||||
reader.endObject();
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("[公告板] json 解析失败: " + ex.getMessage());
|
||||
return out;
|
||||
} finally {
|
||||
if (reader != null) {
|
||||
try { reader.close(); } catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 排序(手动选择排序,不用 Comparator/lambda)----
|
||||
sortBySlot(raw);
|
||||
|
||||
// ---- 排序位冲突 → 自动进位(往后找第一个空位)----
|
||||
Set<Integer> used = new LinkedHashSet<>();
|
||||
for (int i = 0; i < raw.size(); i++) {
|
||||
Notice n = raw.get(i);
|
||||
int s2 = n.slot;
|
||||
if (s2 < 0) s2 = 0;
|
||||
while (used.contains(s2)) s2++;
|
||||
used.add(s2);
|
||||
out.add(new Notice(s2, n.msg, n.color, n.url));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按排序位升序(稳定,手动冒泡,规避 JDK26 javac lambda 类型推断崩溃) */
|
||||
private static void sortBySlot(List<Notice> list) {
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
for (int j = i + 1; j < list.size(); j++) {
|
||||
if (list.get(j).slot < list.get(i).slot) {
|
||||
Notice tmp = list.get(i);
|
||||
list.set(i, list.get(j));
|
||||
list.set(j, tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从文本中提取 ```json ... ``` 代码块内容 */
|
||||
private static String extractJsonBlock(String zone) {
|
||||
int fence = zone.indexOf("```");
|
||||
if (fence < 0) return null;
|
||||
int bodyStart = zone.indexOf('\n', fence);
|
||||
if (bodyStart < 0) return null;
|
||||
int fence2 = zone.indexOf("```", bodyStart);
|
||||
if (fence2 < 0) return null;
|
||||
return zone.substring(bodyStart + 1, fence2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色解析:16 色英文名 / #RRGGBB / r=0 g=0 b=0。
|
||||
*
|
||||
* @return 合法色;空或非法返回 null(调用方回退白色)
|
||||
*/
|
||||
static TextColor parseColor(String s) {
|
||||
if (s == null) return null;
|
||||
String t = s.trim().toLowerCase(Locale.ROOT);
|
||||
if (t.isEmpty()) return null;
|
||||
// 16 色英文名(NamedTextColor.NAMES 的键就是小写下划线名)
|
||||
if (NATIVE_COLORS.contains(t)) {
|
||||
NamedTextColor c = NamedTextColor.NAMES.value(t);
|
||||
return c != null ? c : NamedTextColor.WHITE;
|
||||
}
|
||||
// #RRGGBB
|
||||
if (t.matches("#?[0-9a-f]{6}")) {
|
||||
try {
|
||||
return TextColor.fromHexString(t.startsWith("#") ? t : "#" + t);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// r=0 g=0 b=0
|
||||
Matcher m = Pattern.compile(
|
||||
"(?i)(\\d{1,3})[^0-9]*(\\d{1,3})[^0-9]*(\\d{1,3})")
|
||||
.matcher(t);
|
||||
if (m.find()) {
|
||||
try {
|
||||
int r = Integer.parseInt(m.group(1));
|
||||
int g = Integer.parseInt(m.group(2));
|
||||
int b = Integer.parseInt(m.group(3));
|
||||
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||
return TextColor.color(r, g, b);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
/**
|
||||
* 把公告板刷给单个玩家(PVP 侧边栏管辖范围内自动避让)。
|
||||
*
|
||||
* @param p 玩家
|
||||
*/
|
||||
public void applyTo(Player p) {
|
||||
applyTo(p, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把公告板刷给单个玩家。
|
||||
*
|
||||
* @param p 玩家
|
||||
* @param force true=无视 PVP 区域/竞技场避让(PVP 模块交还侧边栏时用)
|
||||
*/
|
||||
public void applyTo(Player p, boolean force) {
|
||||
if (p == null || !p.isOnline()) return;
|
||||
if (!force && isInPVPSide(p)) return;
|
||||
try {
|
||||
ScoreboardManager mgr = Bukkit.getScoreboardManager();
|
||||
if (mgr == null) return;
|
||||
Scoreboard sb = mgr.getNewScoreboard();
|
||||
Objective obj = sb.registerNewObjective(
|
||||
OBJECTIVE, Criteria.DUMMY, legacy(TITLE));
|
||||
obj.setDisplaySlot(DisplaySlot.SIDEBAR);
|
||||
// 右侧不显示分数数字(公告板只展示内容)
|
||||
try {
|
||||
obj.numberFormat(NumberFormat.blank());
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
int row = 0; // 已渲染行数
|
||||
int overflow = 0; // 超出 15 行被丢弃的行数
|
||||
for (Notice n : notices.values()) {
|
||||
List<String> lines = splitLine(n.msg);
|
||||
for (int li = 0; li < lines.size(); li++) {
|
||||
if (row >= MAX_SIDEBAR_ROWS) {
|
||||
overflow++;
|
||||
continue;
|
||||
}
|
||||
// entry 用纯 ASCII 唯一串(避免同名条目塌缩)
|
||||
String entry = "n" + row;
|
||||
Score sc = obj.getScore(entry);
|
||||
// 侧边栏默认 descending(高分在上)→ 分数递减,排序位小的排最上
|
||||
sc.setScore(MAX_SIDEBAR_ROWS - row);
|
||||
try {
|
||||
sc.customName(buildComponent(n, lines.get(li)));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
row++;
|
||||
}
|
||||
}
|
||||
if (overflow > 0) {
|
||||
plugin.getLogger().warning("[公告板] 原版侧边栏最多显示 "
|
||||
+ MAX_SIDEBAR_ROWS + " 行,已丢弃 "
|
||||
+ overflow + " 行公告(请精简公告.md)");
|
||||
}
|
||||
p.setScoreboard(sb);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 渲染失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建条目显示组件。
|
||||
*
|
||||
* <p>原版侧边栏<b>不解析 entry 里的 § 码</b>,所以颜色/点击事件
|
||||
* 全部通过 {@code customName} 下发。msg 内部允许写 § 颜色码
|
||||
* (legacy 解析),其优先级高于 color 参数 —— 与原版文本语义一致。
|
||||
*/
|
||||
private static Component buildComponent(Notice n, String line) {
|
||||
Component comp = legacy(line);
|
||||
if (n.color != null) {
|
||||
comp = comp.color(n.color);
|
||||
}
|
||||
if (n.url != null) {
|
||||
comp = comp.clickEvent(ClickEvent.openUrl(n.url))
|
||||
.hoverEvent(HoverEvent.showText(
|
||||
Component.text(n.url)));
|
||||
}
|
||||
return comp;
|
||||
}
|
||||
|
||||
/** legacy § 文本 → Component(解析失败退化为纯文本) */
|
||||
private static Component legacy(String s) {
|
||||
if (s == null || s.isEmpty()) return Component.empty();
|
||||
try {
|
||||
return LegacyComponentSerializer.legacySection().deserialize(s);
|
||||
} catch (Throwable t) {
|
||||
return Component.text(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新所有在线玩家 */
|
||||
public void applyAll() {
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
applyTo(p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家是否在 PVP 侧边栏管辖范围内(PVP 区域 / PVP 竞技场)。
|
||||
* 公告板据此避让 —— PVP 模块的侧边栏优先。
|
||||
*/
|
||||
private boolean isInPVPSide(Player p) {
|
||||
if (p == null) return false;
|
||||
try {
|
||||
if (plugin.getPVPManager() != null
|
||||
&& plugin.getPVPManager().isInPVPRegion(p.getName())) {
|
||||
return true;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
if (plugin.getPVPArenaManager() != null
|
||||
&& plugin.getPVPArenaManager().isInPVPArena(p.getName())) {
|
||||
return true;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== 事件 ====================
|
||||
|
||||
/**
|
||||
* 切换世界后重刷公告板(部分世界/插件会重置玩家的计分板)。
|
||||
*/
|
||||
@EventHandler
|
||||
public void onWorldChange(PlayerChangedWorldEvent e) {
|
||||
final Player p = e.getPlayer();
|
||||
Bukkit.getScheduler().runTask(plugin, () -> applyTo(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 超过 15 个字符 → 截断换行(每行最多 15 字,超出的另起一行)。
|
||||
*
|
||||
* @return 该条公告占的行
|
||||
*/
|
||||
static List<String> splitLine(String msg) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (msg == null || msg.isEmpty()) return out;
|
||||
int idx = 0;
|
||||
while (idx < msg.length()) {
|
||||
int end = Math.min(idx + MAX_LINE_CHARS, msg.length());
|
||||
out.add(msg.substring(idx, end));
|
||||
idx = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -505,12 +505,14 @@ public class GarbageManager implements Listener {
|
||||
+ deleted + " §f个过期物品");
|
||||
}
|
||||
} else {
|
||||
// ★ 第34批:非倒垃圾轮次明确告知,避免
|
||||
// 「每轮都在清理」的误解
|
||||
broadcast("§7[垃圾站] §f本轮跳过永久清理"
|
||||
+ "(保留 §e" + n
|
||||
+ " §f轮,每 §e" + n
|
||||
+ " §f轮才永久倒一次垃圾)");
|
||||
// ★ 第35批:明确告知当前轮次 / 下一轮倒垃圾的轮次 /
|
||||
// 还剩几轮才倾倒垃圾(用户要求)
|
||||
final int nextPurge = k + (n - k % n);
|
||||
broadcast("§7[垃圾站] §f当前是第 §e" + k
|
||||
+ " §f轮,第 §e" + nextPurge
|
||||
+ " §f轮倒垃圾,还剩 §e"
|
||||
+ (nextPurge - k)
|
||||
+ " §f轮倾倒垃圾");
|
||||
}
|
||||
// 递增轮次
|
||||
currentRound++;
|
||||
|
||||
@@ -225,6 +225,11 @@ public class Main extends JavaPlugin
|
||||
|
||||
//pvp
|
||||
private PVPManager pvpManager;
|
||||
|
||||
/**
|
||||
* ★ 第35批:公告计分板(侧边栏),数据源 公告/公告.md。
|
||||
*/
|
||||
private AnnouncementBoard announcementBoard;
|
||||
//pvp竞技场(独立世界模式)
|
||||
private PVPArenaManager pvPArenaManager;
|
||||
//pvp练习测试场(独立世界 pvp_test)
|
||||
@@ -510,6 +515,13 @@ public class Main extends JavaPlugin
|
||||
getServer().getPluginManager()
|
||||
.registerEvents(garbage, this);
|
||||
|
||||
// ===== 6.5 公告计分板(第35批)=====
|
||||
announcementBoard = new AnnouncementBoard(this);
|
||||
announcementBoard.init();
|
||||
// 监听切世界(部分插件/世界会重置玩家计分板 -> 需重刷公告板)
|
||||
getServer().getPluginManager()
|
||||
.registerEvents(announcementBoard, this);
|
||||
|
||||
// ===== 7. Radio =====
|
||||
radio = new RadioManager(this);
|
||||
radio.init();
|
||||
@@ -2972,6 +2984,19 @@ public class Main extends JavaPlugin
|
||||
String name = p.getName();
|
||||
String ip = getPlayerIP(p);
|
||||
|
||||
// ===== 公告计分板(第35批)=====
|
||||
// 延后 1 秒:避免与登录/权限流程抢计分板
|
||||
// (applyTo 内部自带 PVP 区域/竞技场避让,这里不再重复判定)
|
||||
if (announcementBoard != null) {
|
||||
final Player ap = p;
|
||||
getServer().getScheduler()
|
||||
.runTaskLater(this, () -> {
|
||||
if (ap.isOnline()) {
|
||||
announcementBoard.applyTo(ap);
|
||||
}
|
||||
}, 20L);
|
||||
}
|
||||
|
||||
// ===== 风控封禁检查:密码错误阈值封禁期间拒绝加入 =====
|
||||
if (riskControl != null && riskControl.isBanned(name)) {
|
||||
int remaining = riskControl.getBanRemainingSeconds(name);
|
||||
@@ -6662,6 +6687,13 @@ public class Main extends JavaPlugin
|
||||
return pvpManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第35批:公告计分板(可能被置 null)。
|
||||
*/
|
||||
public AnnouncementBoard getAnnouncementBoard() {
|
||||
return announcementBoard;
|
||||
}
|
||||
|
||||
public PVPArenaManager getPVPArenaManager() {
|
||||
return pvPArenaManager;
|
||||
}
|
||||
@@ -6669,7 +6701,6 @@ public class Main extends JavaPlugin
|
||||
public PVPTestManager getPVPTestManager() {
|
||||
return pvpTestManager;
|
||||
}
|
||||
|
||||
/** 统一离开 PVP 场景:测试场优先,否则竞技场 */
|
||||
public void leavePVP(Player p) {
|
||||
if (pvpTestManager != null && pvpTestManager.isInTest(p)) {
|
||||
@@ -9271,6 +9302,35 @@ public class Main extends JavaPlugin
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== /公告板(第35批):刷新/查看公告计分板 =====
|
||||
if (cmdName.equals("公告板")) {
|
||||
if (announcementBoard == null) {
|
||||
sender.sendMessage("§c公告板模块未初始化");
|
||||
return true;
|
||||
}
|
||||
if (sender instanceof Player) {
|
||||
Player ap = (Player) sender;
|
||||
if (isAdmin(sender)) {
|
||||
// OP/管理员 → 重载公告文件并刷新全员
|
||||
announcementBoard.reloadAndApply();
|
||||
sender.sendMessage("§a[公告板] §f已重载并刷新,共 §e"
|
||||
+ announcementBoard.getNoticeCount()
|
||||
+ " §f条公告");
|
||||
} else {
|
||||
announcementBoard.applyTo(ap);
|
||||
sender.sendMessage("§a[公告板] §f已刷新,共 §e"
|
||||
+ announcementBoard.getNoticeCount()
|
||||
+ " §f条公告");
|
||||
}
|
||||
} else {
|
||||
announcementBoard.reload();
|
||||
sender.sendMessage("§a[公告板] §f已重载,共 §e"
|
||||
+ announcementBoard.getNoticeCount()
|
||||
+ " §f条公告");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== /json:JSON消息构造器(tellraw/大标题/小标题/actionbar + 颜色)=====
|
||||
// ★ 30批:权限调到 OP 以上(与原版 /title、/tellraw 对齐)。
|
||||
// 非 OP 玩家 → 提示「权限不足」,不开面板、不接管 title/tellraw 报错。
|
||||
|
||||
@@ -512,9 +512,14 @@ public class PVPArenaManager implements Listener {
|
||||
if (p == null) return;
|
||||
String name = p.getName();
|
||||
// 仅将离场玩家个人记分板切回主世界(其战绩行仍保留在PVP记分板供其余玩家查看)
|
||||
// ★ 第35批:优先归还公告板,避免玩家看到空白侧边栏
|
||||
if (p.isOnline()) {
|
||||
if (plugin.getAnnouncementBoard() != null) {
|
||||
plugin.getAnnouncementBoard().applyTo(p, true);
|
||||
} else {
|
||||
p.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
|
||||
}
|
||||
}
|
||||
// 世界未被销毁 → 保留所有参与者战绩,仅刷新榜面(含已离开玩家)
|
||||
if (pvpScoreboard != null) {
|
||||
refreshPVPScoreboard();
|
||||
|
||||
@@ -417,6 +417,16 @@ public class PVPManager implements Listener {
|
||||
return regions.keySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第35批:玩家当前是否在某个 PVP 区域内。
|
||||
* 公告计分板据此避让 —— PVP 区域/竞技场内的侧边栏归 PVP 模块管,
|
||||
* 玩家离开 PVP 区域后由公告板接管回去。
|
||||
*/
|
||||
public boolean isInPVPRegion(String playerName) {
|
||||
if (playerName == null) return false;
|
||||
return inRegion.get(playerName) != null;
|
||||
}
|
||||
|
||||
// ===== 加载 =====
|
||||
|
||||
private void loadRegions() {
|
||||
@@ -1017,6 +1027,14 @@ public class PVPManager implements Listener {
|
||||
|
||||
private void removeScoreboard(
|
||||
Player p) {
|
||||
// ★ 第35批:PVP 区域结束后把侧边栏还给公告板
|
||||
// (原实现塞一个空计分板,玩家看到空白侧边栏)
|
||||
// force=true:此时 inRegion 已移除,强制归还公告板
|
||||
if (plugin.getAnnouncementBoard() != null) {
|
||||
plugin.getAnnouncementBoard()
|
||||
.applyTo(p, true);
|
||||
return;
|
||||
}
|
||||
ScoreboardManager mgr =
|
||||
Bukkit.getScoreboardManager();
|
||||
p.setScoreboard(
|
||||
|
||||
@@ -78,6 +78,9 @@ commands:
|
||||
recycle:
|
||||
description: 打开垃圾回收站
|
||||
usage: /recycle
|
||||
公告板:
|
||||
description: 刷新/查看服务器公告计分板
|
||||
usage: /公告板
|
||||
垃圾清理:
|
||||
description: 手动清理掉落物
|
||||
usage: /垃圾清理
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
## ----公告区域开始----
|
||||
```json
|
||||
{
|
||||
"1": {
|
||||
"msg": "",
|
||||
"color": ""
|
||||
},
|
||||
"2": {
|
||||
"msg": "",
|
||||
"color": ""
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
## ----公告区域结束----
|
||||
|
||||
## 公告板使用手册
|
||||
- 插件主导计分板式公告栏,用于记录和公示服务器的基本信息。<br>
|
||||
- 支持填入任意纯文本。<br>
|
||||
- 空白键值自动跳过
|
||||
|
||||
## 公告区参数选项
|
||||
| 键 | 值 | 必填 | 解释 |
|
||||
| --- | --- | --- |---|
|
||||
| 数字编号 | 花括号 | 必填 | 一条公告的开始 |
|
||||
| msg | 纯文本消息内容 | 必填 | 消息正文 |
|
||||
| color | 颜色英文名或16进制颜色代码,或rgb | 非必填 | 指定颜色,未填写回退MC原版白色 |
|
||||
|
||||
## 示例模板
|
||||
|
||||
```json
|
||||
{
|
||||
"1": {
|
||||
"msg": "这是第一条公告",
|
||||
"color": ""
|
||||
},
|
||||
"2": {
|
||||
"msg": "这是第二条公告",
|
||||
"color": "r=0 g=0 b=0"
|
||||
},
|
||||
"3": {
|
||||
"msg": "这是第三条公告",
|
||||
"color": "#000000"
|
||||
},
|
||||
"4": {
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user