9
0
mirror of https://github.com/Xiao-MoMi/Custom-Nameplates.git synced 2025-12-19 15:09:23 +00:00

improve spectator mode

This commit is contained in:
XiaoMoMi
2024-10-07 17:23:57 +08:00
parent 5df341b2a1
commit f6de779090
18 changed files with 260 additions and 47 deletions

View File

@@ -61,11 +61,6 @@ public abstract class AbstractCNPlayer implements CNPlayer {
this.player = player; this.player = player;
} }
/**
* 将所有处于激活状态的变量统筹起来并返回一个更新任务
*
* @return 更新任务
*/
@Override @Override
public List<Placeholder> activePlaceholdersToRefresh() { public List<Placeholder> activePlaceholdersToRefresh() {
Placeholder[] activePlaceholders = activePlaceholders(); Placeholder[] activePlaceholders = activePlaceholders();

View File

@@ -81,6 +81,14 @@ public interface CNPlayer {
*/ */
boolean isOnline(); boolean isOnline();
/**
*
* Checks if the player is on spectator mode
*
* @return true if the player is on spectator mode, false otherwise
*/
boolean isSpectator();
/** /**
* Returns the scale of the player. * Returns the scale of the player.
* *

View File

@@ -118,15 +118,15 @@ public abstract class AbstractTag implements Tag {
} }
@Override @Override
public void onPlayerCrouching(boolean isCrouching) { public void onOpacityChange(boolean dark) {
for (CNPlayer viewer : viewerArray) { for (CNPlayer viewer : viewerArray) {
onPlayerCrouching(viewer, isCrouching); onOpacityChange(viewer, dark);
} }
} }
@Override @Override
public void onPlayerCrouching(CNPlayer viewer, boolean isCrouching) { public void onOpacityChange(CNPlayer viewer, boolean dark) {
Consumer<List<Object>> modifiers = CustomNameplates.getInstance().getPlatform().createOpacityModifier(isCrouching ? 64 : opacity()); Consumer<List<Object>> modifiers = CustomNameplates.getInstance().getPlatform().createOpacityModifier(dark ? 64 : opacity());
Object packet = CustomNameplates.getInstance().getPlatform().updateTextDisplayPacket(entityID, List.of(modifiers)); Object packet = CustomNameplates.getInstance().getPlatform().updateTextDisplayPacket(entityID, List.of(modifiers));
CustomNameplates.getInstance().getPacketSender().sendPacket(viewer, packet); CustomNameplates.getInstance().getPacketSender().sendPacket(viewer, packet);
} }

View File

@@ -150,20 +150,9 @@ public interface Tag {
*/ */
double getTextHeight(CNPlayer viewer); double getTextHeight(CNPlayer viewer);
/** void onOpacityChange(boolean dark);
* Updates the tag state when the player crouches.
*
* @param isCrouching true if the player is crouching, false otherwise
*/
void onPlayerCrouching(boolean isCrouching);
/** void onOpacityChange(CNPlayer viewer, boolean dark);
* Updates the tag state when the player crouches for a specific viewer.
*
* @param viewer the player to update
* @param isCrouching true if the player is crouching, false otherwise
*/
void onPlayerCrouching(CNPlayer viewer, boolean isCrouching);
/** /**
* Updates the tag scale when the player's scale changes. * Updates the tag scale when the player's scale changes.

View File

@@ -112,4 +112,14 @@ public interface UnlimitedTagManager extends Reloadable {
*/ */
@ApiStatus.Internal @ApiStatus.Internal
void onPlayerAttributeSet(CNPlayer owner, CNPlayer viewer, double scale); void onPlayerAttributeSet(CNPlayer owner, CNPlayer viewer, double scale);
/**
* Internal method for updating a player's game mode
*
* @param owner the player who owns the tags
* @param viewer the player viewing the tags
* @param isSpectator true if the player is a spectator, false otherwise
*/
@ApiStatus.Internal
void onPlayerGameModeChange(CNPlayer owner, CNPlayer viewer, boolean isSpectator);
} }

View File

@@ -27,6 +27,7 @@ public class Tracker {
private boolean isCrouching; private boolean isCrouching;
private double scale; private double scale;
private boolean isSpectator;
private final CNPlayer tracker; private final CNPlayer tracker;
private final CopyOnWriteArrayList<Integer> passengerIDs = new CopyOnWriteArrayList<>(); private final CopyOnWriteArrayList<Integer> passengerIDs = new CopyOnWriteArrayList<>();
@@ -54,6 +55,14 @@ public class Tracker {
isCrouching = crouching; isCrouching = crouching;
} }
public boolean isSpectator() {
return isSpectator;
}
public void setSpectator(boolean spectator) {
isSpectator = spectator;
}
public double getScale() { public double getScale() {
return scale; return scale;
} }

View File

@@ -103,6 +103,10 @@ public class BubbleTag extends AbstractTag {
return canShow; return canShow;
} }
@Override
public void onOpacityChange(CNPlayer viewer, boolean dark) {
}
@Override @Override
public boolean canShow(CNPlayer viewer) { public boolean canShow(CNPlayer viewer) {
if (!viewer.isMet(owner, manager.viewBubbleRequirements())) { if (!viewer.isMet(owner, manager.viewBubbleRequirements())) {

View File

@@ -63,7 +63,7 @@ public class NameTag extends AbstractTag implements RelationalFeature {
owner.position().add(0,(1.8 + (affectedByCrouching() && tracker.isCrouching() && !owner.isFlying() ? -0.3 : 0) + renderer.hatOffset()) * (affectedByScaling() ? tracker.getScale() : 1),0), owner.position().add(0,(1.8 + (affectedByCrouching() && tracker.isCrouching() && !owner.isFlying() ? -0.3 : 0) + renderer.hatOffset()) * (affectedByScaling() ? tracker.getScale() : 1),0),
0f, 0f, 0d, 0f, 0f, 0d,
0, 0, 0, 0, 0, 0,
component, config.backgroundColor(), config.opacity(), config.hasShadow(), config.isSeeThrough(), config.useDefaultBackgroundColor(), component, config.backgroundColor(), opacity(), config.hasShadow(), config.isSeeThrough(), config.useDefaultBackgroundColor(),
config.alignment(), config.viewRange(), config.shadowRadius(), config.shadowStrength(), config.alignment(), config.viewRange(), config.shadowRadius(), config.shadowStrength(),
(affectedByScaling() ? scale(viewer).multiply(tracker.getScale()) : scale(viewer)), (affectedByScaling() ? scale(viewer).multiply(tracker.getScale()) : scale(viewer)),
(affectedByScaling() ? translation(viewer).multiply(tracker.getScale()) : translation(viewer)), (affectedByScaling() ? translation(viewer).multiply(tracker.getScale()) : translation(viewer)),
@@ -144,7 +144,7 @@ public class NameTag extends AbstractTag implements RelationalFeature {
@Override @Override
public double getTextHeight(CNPlayer viewer) { public double getTextHeight(CNPlayer viewer) {
String current = currentText.render(viewer); String current = currentText.render(viewer);
Tracker tracker = viewer.getTracker(owner); Tracker tracker = owner.getTracker(viewer);
int lines = CustomNameplates.getInstance().getAdvanceManager().getLines(current, config.lineWidth()); int lines = CustomNameplates.getInstance().getAdvanceManager().getLines(current, config.lineWidth());
return ((lines * (9+1) + config.translation().y()) * config.scale().y() * (config.affectedByScaling() ? tracker.getScale() : 1)) / 40; return ((lines * (9+1) + config.translation().y()) * config.scale().y() * (config.affectedByScaling() ? tracker.getScale() : 1)) / 40;
} }
@@ -194,7 +194,7 @@ public class NameTag extends AbstractTag implements RelationalFeature {
@Override @Override
public byte opacity() { public byte opacity() {
return config.opacity(); return owner.isSpectator() || (owner.isCrouching() && affectedByCrouching()) ? 64 : config.opacity();
} }
@Override @Override

View File

@@ -71,32 +71,23 @@ public class TagRendererImpl implements TagRenderer {
HashSet<CNPlayer> playersToUpdatePassengers = new HashSet<>(); HashSet<CNPlayer> playersToUpdatePassengers = new HashSet<>();
for (Tag display : tags) { for (Tag display : tags) {
boolean canShow = display.canShow(); boolean canShow = display.canShow();
// 能大众显示
if (canShow) { if (canShow) {
// 当前大众显示
if (display.isShown()) { if (display.isShown()) {
for (CNPlayer nearby : owner.nearbyPlayers()) { for (CNPlayer nearby : owner.nearbyPlayers()) {
// 如果已经展示了
if (display.isShown(nearby)) { if (display.isShown(nearby)) {
// 不满足条件就撤掉
if (!display.canShow(nearby)) { if (!display.canShow(nearby)) {
display.hide(nearby); display.hide(nearby);
} }
} else { } else {
// 未展示,则检测条件,可以就上
if (display.canShow(nearby)) { if (display.canShow(nearby)) {
display.show(nearby); display.show(nearby);
playersToUpdatePassengers.add(nearby); playersToUpdatePassengers.add(nearby);
} }
} }
} }
// 更新一下文字顺序放在后面是为了防止已经被hide的玩家多收一个包
display.tick(); display.tick();
} else { } else {
// 之前隐藏,现在开始大众显示
// 需要重置文字顺序
display.init(); display.init();
// 更新一下文字顺序
display.tick(); display.tick();
display.show(); display.show();
for (CNPlayer nearby : owner.nearbyPlayers()) { for (CNPlayer nearby : owner.nearbyPlayers()) {
@@ -107,8 +98,6 @@ public class TagRendererImpl implements TagRenderer {
} }
} }
} else { } else {
// 不能展示的情况
// 如果已经展示了,就咔掉所有玩家
if (display.isShown()) { if (display.isShown()) {
display.hide(); display.hide();
} }
@@ -264,15 +253,15 @@ public class TagRendererImpl implements TagRenderer {
} }
public void handleEntityDataChange(CNPlayer another, boolean isCrouching) { public void handleEntityDataChange(CNPlayer another, boolean isCrouching) {
Tracker properties = owner.getTracker(another); Tracker tracker = owner.getTracker(another);
// should never be null // should never be null
if (properties == null) return; if (tracker == null) return;
properties.setCrouching(isCrouching); tracker.setCrouching(isCrouching);
for (Tag display : this.tags) { for (Tag display : this.tags) {
if (display.affectedByCrouching()) { if (display.affectedByCrouching()) {
if (display.isShown()) { if (display.isShown()) {
if (display.isShown(another)) { if (display.isShown(another)) {
display.onPlayerCrouching(another, isCrouching); display.onOpacityChange(another, isCrouching || tracker.isSpectator());
} }
} }
} }
@@ -280,11 +269,10 @@ public class TagRendererImpl implements TagRenderer {
} }
public void handleAttributeChange(CNPlayer another, double scale) { public void handleAttributeChange(CNPlayer another, double scale) {
boolean updatePassengers = false; Tracker tracker = owner.getTracker(another);
Tracker properties = owner.getTracker(another);
// should never be null // should never be null
if (properties == null) return; if (tracker == null) return;
properties.setScale(scale); tracker.setScale(scale);
for (Tag display : this.tags) { for (Tag display : this.tags) {
if (display.affectedByScaling()) { if (display.affectedByScaling()) {
if (display.isShown()) { if (display.isShown()) {
@@ -295,4 +283,18 @@ public class TagRendererImpl implements TagRenderer {
} }
} }
} }
public void handleGameModeChange(CNPlayer another, boolean isSpectator) {
Tracker tracker = owner.getTracker(another);
// can be null
if (tracker == null) return;
tracker.setSpectator(isSpectator);
for (Tag display : this.tags) {
if (display.isShown()) {
if (display.isShown(another)) {
display.onOpacityChange(another, isSpectator || tracker.isCrouching());
}
}
}
}
} }

View File

@@ -67,6 +67,7 @@ public class UnlimitedTagManagerImpl implements UnlimitedTagManager, JoinQuitLis
Tracker tracker = player.addPlayerToTracker(player); Tracker tracker = player.addPlayerToTracker(player);
tracker.setScale(player.scale()); tracker.setScale(player.scale());
tracker.setCrouching(player.isCrouching()); tracker.setCrouching(player.isCrouching());
tracker.setSpectator(player.isSpectator());
plugin.getUnlimitedTagManager().onAddPlayer(player, player); plugin.getUnlimitedTagManager().onAddPlayer(player, player);
((AbstractCNPlayer) player).setPreviewing(true); ((AbstractCNPlayer) player).setPreviewing(true);
} }
@@ -179,6 +180,14 @@ public class UnlimitedTagManagerImpl implements UnlimitedTagManager, JoinQuitLis
} }
} }
@Override
public void onPlayerGameModeChange(CNPlayer owner, CNPlayer viewer, boolean isSpectator) {
TagRendererImpl controller = tagRenderers.get(owner.uuid());
if (controller != null) {
controller.handleGameModeChange(viewer, isSpectator);
}
}
private void loadConfig() { private void loadConfig() {
plugin.getConfigManager().saveResource("configs" + File.separator + "nameplate.yml"); plugin.getConfigManager().saveResource("configs" + File.separator + "nameplate.yml");
YamlDocument document = plugin.getConfigManager().loadData(new File(plugin.getDataDirectory().toFile(), "configs" + File.separator + "nameplate.yml")); YamlDocument document = plugin.getConfigManager().loadData(new File(plugin.getDataDirectory().toFile(), "configs" + File.separator + "nameplate.yml"));

View File

@@ -1,7 +1,6 @@
# Requirements for sending the bubble # Requirements for sending the bubble
sender-requirements: sender-requirements:
permission: bubbles.send permission: bubbles.send
'!gamemode': spectator
potion-effect: "INVISIBILITY<0" potion-effect: "INVISIBILITY<0"
viewer-requirements: viewer-requirements:

View File

@@ -20,6 +20,7 @@ package net.momirealms.customnameplates.bukkit;
import net.momirealms.customnameplates.api.AbstractCNPlayer; import net.momirealms.customnameplates.api.AbstractCNPlayer;
import net.momirealms.customnameplates.api.CustomNameplates; import net.momirealms.customnameplates.api.CustomNameplates;
import net.momirealms.customnameplates.api.util.Vector3; import net.momirealms.customnameplates.api.util.Vector3;
import org.bukkit.GameMode;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.Registry; import org.bukkit.Registry;
@@ -96,6 +97,11 @@ public class BukkitCNPlayer extends AbstractCNPlayer {
return player.isOnline(); return player.isOnline();
} }
@Override
public boolean isSpectator() {
return player.getGameMode() == GameMode.SPECTATOR;
}
@Override @Override
public Set<Integer> passengers() { public Set<Integer> passengers() {
return player.getPassengers().stream().map(Entity::getEntityId).collect(Collectors.toSet()); return player.getPassengers().stream().map(Entity::getEntityId).collect(Collectors.toSet());

View File

@@ -40,6 +40,7 @@ import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; import java.util.function.Consumer;
public class BukkitPlatform implements Platform { public class BukkitPlatform implements Platform {
@@ -178,6 +179,29 @@ public class BukkitPlatform implements Platform {
} }
}, "PacketPlayOutEntityDestroy", "ClientboundRemoveEntitiesPacket"); }, "PacketPlayOutEntityDestroy", "ClientboundRemoveEntitiesPacket");
registerPacketConsumer((player, event, packet) -> {
try {
EnumSet<?> enums = (EnumSet<?>) Reflections.field$ClientboundPlayerInfoUpdatePacket$actions.get(packet);
if (enums == null) return;
if (!enums.contains(Reflections.enum$ClientboundPlayerInfoUpdatePacket$Action$UPDATE_GAME_MODE)) return;
List<Object> entries = (List<Object>) Reflections.field$ClientboundPlayerInfoUpdatePacket$entries.get(packet);
for (Object entry : entries) {
UUID uuid = (UUID) Reflections.field$ClientboundPlayerInfoUpdatePacket$Entry$profileId.get(entry);
if (uuid == null) continue;
Object gameType = Reflections.field$ClientboundPlayerInfoUpdatePacket$Entry$gameMode.get(entry);
if (gameType == null) continue;
int mode = (int) Reflections.method$GameType$getId.invoke(gameType);
boolean isSpectator = mode == 3;
CNPlayer another = CustomNameplates.getInstance().getPlayer(uuid);
if (another != null) {
CustomNameplates.getInstance().getUnlimitedTagManager().onPlayerGameModeChange(another, player, isSpectator);
}
}
} catch (ReflectiveOperationException e) {
CustomNameplates.getInstance().getPluginLogger().severe("Failed to handle ClientboundPlayerInfoUpdatePacket", e);
}
}, "ClientboundPlayerInfoUpdatePacket");
// for cosmetic plugin compatibility // for cosmetic plugin compatibility
registerPacketConsumer((player, event, packet) -> { registerPacketConsumer((player, event, packet) -> {
try { try {

View File

@@ -49,6 +49,7 @@ public class BukkitCommandManager extends AbstractCommandManager<CommandSender>
new DebugPerformanceCommand(this, plugin), new DebugPerformanceCommand(this, plugin),
new DebugWidthCommand(this, plugin), new DebugWidthCommand(this, plugin),
new DebugLinesCommand(this, plugin), new DebugLinesCommand(this, plugin),
new DebugTestCommand(this, plugin),
new NameplatesEquipCommand(this, plugin), new NameplatesEquipCommand(this, plugin),
new NameplatesUnEquipCommand(this, plugin), new NameplatesUnEquipCommand(this, plugin),
new NameplatesListCommand(this, plugin), new NameplatesListCommand(this, plugin),

View File

@@ -0,0 +1,46 @@
/*
* Copyright (C) <2024> <XiaoMoMi>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.momirealms.customnameplates.bukkit.command.feature;
import net.momirealms.customnameplates.bukkit.BukkitCustomNameplates;
import net.momirealms.customnameplates.bukkit.command.BukkitCommandFeature;
import net.momirealms.customnameplates.common.command.CustomNameplatesCommandManager;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.incendo.cloud.Command;
import org.incendo.cloud.CommandManager;
public class DebugTestCommand extends BukkitCommandFeature<CommandSender> {
public DebugTestCommand(CustomNameplatesCommandManager<CommandSender> commandManager, BukkitCustomNameplates plugin) {
super(commandManager, plugin);
}
@Override
public Command.Builder<? extends CommandSender> assembleCommand(CommandManager<CommandSender> manager, Command.Builder<CommandSender> builder) {
return builder
.senderType(Player.class)
.handler(context -> {
});
}
@Override
public String getFeatureID() {
return "debug_test";
}
}

View File

@@ -26,6 +26,7 @@ import java.lang.reflect.Constructor;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Collection; import java.util.Collection;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -741,4 +742,106 @@ public class Reflections {
clazz$AttributeModifier, double.class, 0 clazz$AttributeModifier, double.class, 0
) )
); );
public static final Class<?> clazz$ClientboundGameEventPacket = requireNonNull(
ReflectionUtils.getClazz(
BukkitReflectionUtils.assembleMCClass("network.protocol.game.ClientboundGameEventPacket"),
BukkitReflectionUtils.assembleMCClass("network.protocol.game.PacketPlayOutGameStateChange")
)
);
public static final Class<?> clazz$ClientboundGameEventPacket$Type = requireNonNull(
ReflectionUtils.getClazz(
BukkitReflectionUtils.assembleMCClass("network.protocol.game.ClientboundGameEventPacket$Type"),
BukkitReflectionUtils.assembleMCClass("network.protocol.game.PacketPlayOutGameStateChange$a")
)
);
public static final Field field$ClientboundGameEventPacket$event = requireNonNull(
ReflectionUtils.getDeclaredField(
clazz$ClientboundGameEventPacket, clazz$ClientboundGameEventPacket$Type, 0
)
);
public static final Field field$ClientboundGameEventPacket$param = requireNonNull(
ReflectionUtils.getDeclaredField(
clazz$ClientboundGameEventPacket, float.class, 0
)
);
public static final Field field$ClientboundGameEventPacket$Type$id = requireNonNull(
ReflectionUtils.getDeclaredField(
clazz$ClientboundGameEventPacket$Type, int.class, 0
)
);
public static final Class<?> clazz$ClientboundPlayerInfoUpdatePacket = requireNonNull(
ReflectionUtils.getClazz(
BukkitReflectionUtils.assembleMCClass("network.protocol.game.ClientboundPlayerInfoUpdatePacket")
)
);
public static final Field field$ClientboundPlayerInfoUpdatePacket$actions = requireNonNull(
ReflectionUtils.getDeclaredField(
clazz$ClientboundPlayerInfoUpdatePacket, EnumSet.class, 0
)
);
public static final Field field$ClientboundPlayerInfoUpdatePacket$entries = requireNonNull(
ReflectionUtils.getDeclaredField(
clazz$ClientboundPlayerInfoUpdatePacket, List.class, 0
)
);
public static final Class<?> clazz$ClientboundPlayerInfoUpdatePacket$Action = requireNonNull(
ReflectionUtils.getClazz(
BukkitReflectionUtils.assembleMCClass("network.protocol.game.ClientboundPlayerInfoUpdatePacket$Action"),
BukkitReflectionUtils.assembleMCClass("network.protocol.game.ClientboundPlayerInfoUpdatePacket$a")
)
);
public static final Enum<?> enum$ClientboundPlayerInfoUpdatePacket$Action$UPDATE_GAME_MODE;
static {
Enum<?> updateGameMode;
try {
updateGameMode = Enum.valueOf((Class<Enum>) clazz$ClientboundPlayerInfoUpdatePacket$Action, "UPDATE_GAME_MODE");
} catch (Exception e) {
updateGameMode = Enum.valueOf((Class<Enum>) clazz$ClientboundPlayerInfoUpdatePacket$Action, "c");
}
enum$ClientboundPlayerInfoUpdatePacket$Action$UPDATE_GAME_MODE = updateGameMode;
}
public static final Class<?> clazz$ClientboundPlayerInfoUpdatePacket$Entry = requireNonNull(
ReflectionUtils.getClazz(
BukkitReflectionUtils.assembleMCClass("network.protocol.game.ClientboundPlayerInfoUpdatePacket$Entry"),
BukkitReflectionUtils.assembleMCClass("network.protocol.game.ClientboundPlayerInfoUpdatePacket$b")
)
);
public static final Class<?> clazz$GameType = requireNonNull(
ReflectionUtils.getClazz(
BukkitReflectionUtils.assembleMCClass("world.level.GameType"),
BukkitReflectionUtils.assembleMCClass("world.level.EnumGamemode")
)
);
public static final Field field$ClientboundPlayerInfoUpdatePacket$Entry$gameMode = requireNonNull(
ReflectionUtils.getDeclaredField(
clazz$ClientboundPlayerInfoUpdatePacket$Entry, clazz$GameType, 0
)
);
public static final Field field$ClientboundPlayerInfoUpdatePacket$Entry$profileId = requireNonNull(
ReflectionUtils.getDeclaredField(
clazz$ClientboundPlayerInfoUpdatePacket$Entry, UUID.class, 0
)
);
public static final Method method$GameType$getId = requireNonNull(
ReflectionUtils.getMethod(
clazz$GameType, new String[] { "getId", "a" }
)
);
} }

View File

@@ -133,4 +133,12 @@ bubbles_list:
enable: true enable: true
permission: bubbles.command.list permission: bubbles.command.list
usage: usage:
- /bubbles list - /bubbles list
# A command to test some stuffs
# Usage: [COMMAND]
debug_test:
enable: false
permission: nameplates.command.debug.test
usage:
- /nameplates debug test

View File

@@ -1,6 +1,6 @@
# Project settings # Project settings
# Rule: [major update].[feature update].[bug fix] # Rule: [major update].[feature update].[bug fix]
project_version=3.0.0-beta-1 project_version=3.0.0-beta-2
config_version=36 config_version=36
project_group=net.momirealms project_group=net.momirealms