Initial sanctuary sources
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
package fr.koka99cab.helloworld.client;
|
||||
|
||||
import fr.koka99cab.helloworld.HelloWorldMod;
|
||||
import fr.koka99cab.helloworld.client.rpc.SanctuaryDiscordPresence;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
|
||||
public class HelloWorldClient implements ClientModInitializer {
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
HelloWorldMod.LOGGER.info("helloworld client initialise.");
|
||||
SanctuaryDiscordPresence.initialize();
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package fr.koka99cab.helloworld.client.loading;
|
||||
|
||||
import fr.koka99cab.helloworld.HelloWorldMod;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public final class LoadingScreenVisuals {
|
||||
public static final Identifier LOADING_SHEET =
|
||||
Identifier.fromNamespaceAndPath(HelloWorldMod.MOD_ID, "textures/gui/loading_sheet.png");
|
||||
public static final String LOADING_SHEET_RESOURCE = "/assets/helloworld/textures/gui/loading_sheet.png";
|
||||
public static final int BACKGROUND_ARGB = 0xFF050505;
|
||||
public static final int FRAME_WIDTH = 750;
|
||||
public static final int FRAME_HEIGHT = 750;
|
||||
public static final int FRAME_COUNT = 11;
|
||||
public static final int SHEET_WIDTH = FRAME_WIDTH * FRAME_COUNT;
|
||||
private static int currentFrameIndex;
|
||||
|
||||
private LoadingScreenVisuals() {
|
||||
}
|
||||
|
||||
public static int currentFrameU() {
|
||||
return currentFrameIndex * FRAME_WIDTH;
|
||||
}
|
||||
|
||||
public static void advanceFrame() {
|
||||
currentFrameIndex = (currentFrameIndex + 1) % FRAME_COUNT;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package fr.koka99cab.helloworld.client.loading;
|
||||
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import net.minecraft.client.renderer.texture.MipmapStrategy;
|
||||
import net.minecraft.client.renderer.texture.ReloadableTexture;
|
||||
import net.minecraft.client.renderer.texture.TextureContents;
|
||||
import net.minecraft.client.resources.metadata.texture.TextureMetadataSection;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
|
||||
public final class LoadingSheetTexture extends ReloadableTexture {
|
||||
private static final TextureMetadataSection METADATA =
|
||||
new TextureMetadataSection(true, true, MipmapStrategy.MEAN, 0.0F);
|
||||
|
||||
public LoadingSheetTexture() {
|
||||
super(LoadingScreenVisuals.LOADING_SHEET);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextureContents loadContents(ResourceManager resourceManager) throws IOException {
|
||||
try (InputStream inputStream = LoadingSheetTexture.class.getResourceAsStream(LoadingScreenVisuals.LOADING_SHEET_RESOURCE)) {
|
||||
if (inputStream == null) {
|
||||
throw new IOException("Missing loading sheet resource: " + LoadingScreenVisuals.LOADING_SHEET_RESOURCE);
|
||||
}
|
||||
|
||||
return new TextureContents(NativeImage.read(inputStream), METADATA);
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package fr.koka99cab.helloworld.client.mixin;
|
||||
|
||||
import com.mojang.blaze3d.pipeline.RenderPipeline;
|
||||
import fr.koka99cab.helloworld.client.loading.LoadingSheetTexture;
|
||||
import fr.koka99cab.helloworld.client.loading.LoadingScreenVisuals;
|
||||
import java.util.function.IntSupplier;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.screens.LoadingOverlay;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.client.renderer.texture.TextureManager;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.ARGB;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Mutable;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(LoadingOverlay.class)
|
||||
public abstract class LoadingOverlayMixin {
|
||||
@Shadow
|
||||
@Final
|
||||
@Mutable
|
||||
private static IntSupplier BRAND_BACKGROUND;
|
||||
|
||||
@Inject(method = "<clinit>", at = @At("TAIL"))
|
||||
private static void helloworld$setLoadingBackground(CallbackInfo ci) {
|
||||
BRAND_BACKGROUND = () -> LoadingScreenVisuals.BACKGROUND_ARGB;
|
||||
}
|
||||
|
||||
@Inject(method = "registerTextures", at = @At("TAIL"))
|
||||
private static void helloworld$registerLoadingSheet(TextureManager textureManager, CallbackInfo ci) {
|
||||
textureManager.registerAndLoad(LoadingScreenVisuals.LOADING_SHEET, new LoadingSheetTexture());
|
||||
}
|
||||
|
||||
@Inject(method = "tick", at = @At("HEAD"))
|
||||
private void helloworld$advanceLoadingAnimation(CallbackInfo ci) {
|
||||
LoadingScreenVisuals.advanceFrame();
|
||||
}
|
||||
|
||||
@Redirect(
|
||||
method = "extractRenderState",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;blit(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIFFIIIIIII)V",
|
||||
ordinal = 0
|
||||
)
|
||||
)
|
||||
private void helloworld$drawLoadingChicken(
|
||||
GuiGraphicsExtractor graphics,
|
||||
RenderPipeline pipeline,
|
||||
Identifier texture,
|
||||
int x,
|
||||
int y,
|
||||
float u,
|
||||
float v,
|
||||
int width,
|
||||
int height,
|
||||
int regionWidth,
|
||||
int regionHeight,
|
||||
int textureWidth,
|
||||
int textureHeight,
|
||||
int color
|
||||
) {
|
||||
int drawSize = width;
|
||||
int drawX = x + width - drawSize / 2;
|
||||
int drawY = y - Math.max(0, (drawSize - height) / 2);
|
||||
graphics.blit(
|
||||
RenderPipelines.GUI_TEXTURED,
|
||||
LoadingScreenVisuals.LOADING_SHEET,
|
||||
drawX,
|
||||
drawY,
|
||||
LoadingScreenVisuals.currentFrameU(),
|
||||
0.0F,
|
||||
drawSize,
|
||||
drawSize,
|
||||
LoadingScreenVisuals.FRAME_WIDTH,
|
||||
LoadingScreenVisuals.FRAME_HEIGHT,
|
||||
LoadingScreenVisuals.SHEET_WIDTH,
|
||||
LoadingScreenVisuals.FRAME_HEIGHT,
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
@Redirect(
|
||||
method = "extractRenderState",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;blit(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIFFIIIIIII)V",
|
||||
ordinal = 1
|
||||
)
|
||||
)
|
||||
private void helloworld$skipSecondVanillaLogoHalf(
|
||||
GuiGraphicsExtractor graphics,
|
||||
RenderPipeline pipeline,
|
||||
Identifier texture,
|
||||
int x,
|
||||
int y,
|
||||
float u,
|
||||
float v,
|
||||
int width,
|
||||
int height,
|
||||
int regionWidth,
|
||||
int regionHeight,
|
||||
int textureWidth,
|
||||
int textureHeight,
|
||||
int color
|
||||
) {
|
||||
}
|
||||
|
||||
@Redirect(
|
||||
method = "extractProgressBar",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/util/ARGB;color(IIII)I"
|
||||
)
|
||||
)
|
||||
private int helloworld$whiteProgressBar(int alpha, int red, int green, int blue) {
|
||||
return ARGB.color(alpha, 255, 255, 255);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package fr.koka99cab.helloworld.client.mixin;
|
||||
|
||||
import fr.koka99cab.helloworld.client.title.CustomTitleIconClient;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(Minecraft.class)
|
||||
public abstract class MinecraftClientMixin {
|
||||
@Inject(method = "run", at = @At("HEAD"))
|
||||
private void helloworld$applyWindowTitleAndIcon(CallbackInfo callbackInfo) {
|
||||
Minecraft minecraft = (Minecraft) (Object) this;
|
||||
CustomTitleIconClient.apply(minecraft.getWindow());
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package fr.koka99cab.helloworld.client.mixin;
|
||||
|
||||
import fr.koka99cab.helloworld.motd.AnimationPayload;
|
||||
import fr.koka99cab.helloworld.motd.GifFrameData;
|
||||
import fr.koka99cab.helloworld.motd.GifIconLoader;
|
||||
import fr.koka99cab.helloworld.motd.MotdPlusPlusMod;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.screens.FaviconTexture;
|
||||
import net.minecraft.client.multiplayer.ServerData;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.util.Util;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.gen.Invoker;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Mixin(targets = "net.minecraft.client.gui.screens.multiplayer.ServerSelectionList$OnlineServerEntry")
|
||||
public abstract class OnlineServerEntryMixin {
|
||||
@Shadow @Final private ServerData serverData;
|
||||
@Shadow @Final private FaviconTexture icon;
|
||||
|
||||
@Unique
|
||||
private String motdpp$lastPayloadInsertion;
|
||||
|
||||
@Unique
|
||||
private AnimationPayload motdpp$payload;
|
||||
|
||||
@Unique
|
||||
private List<GifFrameData> motdpp$iconFrames = List.of();
|
||||
|
||||
@Unique
|
||||
private long motdpp$receivedAtMillis;
|
||||
|
||||
@Unique
|
||||
private byte[] motdpp$lastRenderedIconBytes;
|
||||
|
||||
@Invoker("uploadServerIcon")
|
||||
abstract boolean motdpp$uploadServerIcon(byte[] bytes);
|
||||
|
||||
@Inject(method = "extractContent", at = @At("HEAD"))
|
||||
private void motdpp$animateLocally(GuiGraphicsExtractor graphics, int mouseX, int mouseY, boolean hovered, float delta, CallbackInfo ci) {
|
||||
this.motdpp$tryDecodePayload();
|
||||
|
||||
if (this.motdpp$payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long now = Util.getMillis();
|
||||
long elapsedTicks = this.motdpp$payload.tickOffset + Math.max(0L, (now - this.motdpp$receivedAtMillis) / 50L);
|
||||
this.serverData.motd = Component.literal(this.motdpp$frameAt(elapsedTicks))
|
||||
.withStyle(Style.EMPTY.withInsertion(this.motdpp$lastPayloadInsertion));
|
||||
|
||||
byte[] iconBytes = this.motdpp$iconBytesAt(elapsedTicks);
|
||||
|
||||
if (iconBytes != null && !java.util.Arrays.equals(this.motdpp$lastRenderedIconBytes, iconBytes)) {
|
||||
if (this.motdpp$uploadServerIcon(iconBytes)) {
|
||||
this.motdpp$lastRenderedIconBytes = iconBytes.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Unique
|
||||
private void motdpp$tryDecodePayload() {
|
||||
String insertion = this.serverData.motd == null ? null : this.serverData.motd.getStyle().getInsertion();
|
||||
|
||||
if (Objects.equals(this.motdpp$lastPayloadInsertion, insertion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.motdpp$lastPayloadInsertion = insertion;
|
||||
this.motdpp$payload = AnimationPayload.decode(insertion, MotdPlusPlusMod.LOGGER);
|
||||
this.motdpp$receivedAtMillis = Util.getMillis();
|
||||
this.motdpp$lastRenderedIconBytes = null;
|
||||
this.motdpp$iconFrames = this.motdpp$payload == null
|
||||
? List.of()
|
||||
: GifIconLoader.load(this.motdpp$payload.gifBytes(), MotdPlusPlusMod.LOGGER);
|
||||
}
|
||||
|
||||
@Unique
|
||||
private String motdpp$frameAt(long elapsedTicks) {
|
||||
List<String> frames = this.motdpp$payload.motdFrames == null || this.motdpp$payload.motdFrames.isEmpty()
|
||||
? List.of("sanctuary")
|
||||
: this.motdpp$payload.motdFrames;
|
||||
String text = this.motdpp$payload.scrollText == null ? "" : this.motdpp$payload.scrollText;
|
||||
|
||||
if ("frames".equals(this.motdpp$payload.normalizedMode()) || (text.isBlank() && !frames.isEmpty())) {
|
||||
int frameIndex = (int) ((elapsedTicks / this.motdpp$payload.normalizedMotdSpeedTicks()) % frames.size());
|
||||
String frame = frames.get(frameIndex);
|
||||
return frame.isBlank() ? "sanctuary" : frame;
|
||||
}
|
||||
|
||||
String padding = this.motdpp$payload.scrollPadding == null ? " " : this.motdpp$payload.scrollPadding;
|
||||
int width = Math.max(1, this.motdpp$payload.scrollWindowWidth);
|
||||
|
||||
if (text.isBlank()) {
|
||||
text = String.join(" | ", frames);
|
||||
}
|
||||
|
||||
int[] codePoints = (text + padding).codePoints().toArray();
|
||||
|
||||
if (codePoints.length == 0) {
|
||||
return frames.getFirst();
|
||||
}
|
||||
|
||||
int offset = (int) ((elapsedTicks / this.motdpp$payload.normalizedMotdSpeedTicks()) % codePoints.length);
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
for (int index = 0; index < width; index++) {
|
||||
builder.appendCodePoint(codePoints[(offset + index) % codePoints.length]);
|
||||
}
|
||||
|
||||
String frame = builder.toString();
|
||||
return frame.isBlank() ? frames.getFirst() : frame;
|
||||
}
|
||||
|
||||
@Unique
|
||||
private byte[] motdpp$iconBytesAt(long elapsedTicks) {
|
||||
if (this.motdpp$iconFrames.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.motdpp$iconFrames.size() == 1) {
|
||||
return this.motdpp$iconFrames.getFirst().pngBytes();
|
||||
}
|
||||
|
||||
long cycleDuration = this.motdpp$iconFrames.stream()
|
||||
.mapToLong(GifFrameData::durationTicks)
|
||||
.sum();
|
||||
|
||||
if (cycleDuration <= 0L) {
|
||||
return this.motdpp$iconFrames.getFirst().pngBytes();
|
||||
}
|
||||
|
||||
long tickInCycle = elapsedTicks % cycleDuration;
|
||||
long cursor = 0L;
|
||||
|
||||
for (GifFrameData frame : this.motdpp$iconFrames) {
|
||||
cursor += frame.durationTicks();
|
||||
|
||||
if (tickInCycle < cursor) {
|
||||
return frame.pngBytes();
|
||||
}
|
||||
}
|
||||
|
||||
return this.motdpp$iconFrames.getLast().pngBytes();
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package fr.koka99cab.helloworld.client.mixin;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.layouts.FrameLayout;
|
||||
import net.minecraft.client.gui.layouts.GridLayout;
|
||||
import net.minecraft.client.gui.screens.PauseScreen;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.gui.screens.ShareToLanScreen;
|
||||
import net.minecraft.client.gui.screens.options.OptionsScreen;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.network.chat.CommonComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(PauseScreen.class)
|
||||
public abstract class PauseScreenMixin extends Screen {
|
||||
private static final int BUTTON_WIDTH = 204;
|
||||
|
||||
@Shadow
|
||||
private Button disconnectButton;
|
||||
|
||||
protected PauseScreenMixin(Component title) {
|
||||
super(title);
|
||||
}
|
||||
|
||||
@Inject(method = "createPauseMenu", at = @At("HEAD"), cancellable = true)
|
||||
private void helloworld$createCompactPauseMenu(CallbackInfo ci) {
|
||||
GridLayout layout = new GridLayout();
|
||||
layout.defaultCellSetting().padding(4, 4, 4, 0);
|
||||
|
||||
GridLayout.RowHelper rowHelper = layout.createRowHelper(1);
|
||||
rowHelper.addChild(
|
||||
Button.builder(Component.translatable("menu.returnToGame"), button -> this.helloworld$returnToGame())
|
||||
.width(BUTTON_WIDTH)
|
||||
.build(),
|
||||
1,
|
||||
layout.newCellSettings().paddingTop(50)
|
||||
);
|
||||
rowHelper.addChild(this.helloworld$screenButton(
|
||||
Component.translatable("menu.options"),
|
||||
() -> new OptionsScreen((Screen) (Object) this, this.minecraft.options, true)
|
||||
));
|
||||
|
||||
if (this.minecraft.hasSingleplayerServer() && !this.minecraft.getSingleplayerServer().isPublished()) {
|
||||
rowHelper.addChild(this.helloworld$screenButton(
|
||||
Component.translatable("menu.shareToLan"),
|
||||
() -> new ShareToLanScreen((Screen) (Object) this)
|
||||
));
|
||||
}
|
||||
|
||||
Button quitButton = Button.builder(
|
||||
CommonComponents.disconnectButtonLabel(this.minecraft.isLocalServer()),
|
||||
this::helloworld$handleDisconnect
|
||||
).width(BUTTON_WIDTH).build();
|
||||
this.disconnectButton = quitButton;
|
||||
rowHelper.addChild(quitButton);
|
||||
|
||||
layout.arrangeElements();
|
||||
FrameLayout.alignInRectangle(layout, 0, 0, this.width, this.height, 0.5F, 0.25F);
|
||||
layout.visitChildren(element -> element.visitWidgets(this::addRenderableWidget));
|
||||
ci.cancel();
|
||||
}
|
||||
|
||||
private Button helloworld$screenButton(Component label, Supplier<Screen> screenSupplier) {
|
||||
return Button.builder(label, button -> this.minecraft.setScreen(screenSupplier.get()))
|
||||
.width(BUTTON_WIDTH)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void helloworld$handleDisconnect(Button button) {
|
||||
button.active = false;
|
||||
this.minecraft.getReportingContext().draftReportHandled(
|
||||
this.minecraft,
|
||||
(Screen) (Object) this,
|
||||
() -> this.minecraft.disconnectFromWorld(ClientLevel.DEFAULT_QUIT_MESSAGE),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private void helloworld$returnToGame() {
|
||||
this.minecraft.setScreen(null);
|
||||
this.minecraft.mouseHandler.grabMouse();
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package fr.koka99cab.helloworld.client.mixin;
|
||||
|
||||
import com.mojang.realmsclient.gui.screens.RealmsNotificationsScreen;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.SpriteIconButton;
|
||||
import net.minecraft.client.gui.components.events.GuiEventListener;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.gui.screens.TitleScreen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(TitleScreen.class)
|
||||
public abstract class TitleScreenMixin extends Screen {
|
||||
private static final int SECONDARY_ROW_MIN_X_OFFSET = 130;
|
||||
private static final int SECONDARY_ROW_MAX_X_OFFSET = 130;
|
||||
private static final int OPTIONS_SECTION_GAP = 36;
|
||||
private static final String LICENSE_TEXT = "sanctuary © KOKA99CAB - LGPL-3.0-or-later";
|
||||
private static final int FOOTER_TEXT_COLOR = 0xFFFFFFFF;
|
||||
|
||||
@Shadow
|
||||
private RealmsNotificationsScreen realmsNotificationsScreen;
|
||||
|
||||
protected TitleScreenMixin(Component title) {
|
||||
super(title);
|
||||
}
|
||||
|
||||
@Inject(method = "init", at = @At("TAIL"))
|
||||
private void helloworld$compactTitleMenu(CallbackInfo ci) {
|
||||
this.realmsNotificationsScreen = null;
|
||||
|
||||
if (this.minecraft.isDemo()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Button realmsButton = null;
|
||||
AbstractWidget singleplayerButton = null;
|
||||
AbstractWidget multiplayerButton = null;
|
||||
AbstractWidget optionsButton = null;
|
||||
AbstractWidget quitButton = null;
|
||||
String singleplayerText = Component.translatable("menu.singleplayer").getString();
|
||||
String multiplayerText = Component.translatable("menu.multiplayer").getString();
|
||||
String realmsText = Component.translatable("menu.online").getString();
|
||||
String optionsText = Component.translatable("menu.options").getString();
|
||||
String quitText = Component.translatable("menu.quit").getString();
|
||||
|
||||
for (GuiEventListener child : this.children()) {
|
||||
if (!(child instanceof AbstractWidget widget)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String widgetText = widget.getMessage().getString();
|
||||
if (realmsButton == null && child instanceof Button button && widgetText.equals(realmsText)) {
|
||||
realmsButton = button;
|
||||
}
|
||||
|
||||
if (singleplayerButton == null && widgetText.equals(singleplayerText)) {
|
||||
singleplayerButton = widget;
|
||||
}
|
||||
|
||||
if (multiplayerButton == null && widgetText.equals(multiplayerText)) {
|
||||
multiplayerButton = widget;
|
||||
}
|
||||
|
||||
if (optionsButton == null && widgetText.equals(optionsText)) {
|
||||
optionsButton = widget;
|
||||
}
|
||||
|
||||
if (quitButton == null && widgetText.equals(quitText)) {
|
||||
quitButton = widget;
|
||||
}
|
||||
}
|
||||
|
||||
if (realmsButton != null) {
|
||||
this.removeWidget(realmsButton);
|
||||
}
|
||||
|
||||
if (singleplayerButton == null || multiplayerButton == null || optionsButton == null || quitButton == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int centerX = this.width / 2;
|
||||
int secondaryRowY = optionsButton.getY();
|
||||
List<AbstractWidget> widgetsToRemove = new ArrayList<>();
|
||||
|
||||
for (GuiEventListener child : this.children()) {
|
||||
if (!(child instanceof AbstractWidget widget)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (widget.getY() != secondaryRowY) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int widgetX = widget.getX();
|
||||
if (widgetX < centerX - SECONDARY_ROW_MIN_X_OFFSET || widgetX > centerX + SECONDARY_ROW_MAX_X_OFFSET) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (widget instanceof SpriteIconButton) {
|
||||
widgetsToRemove.add(widget);
|
||||
}
|
||||
}
|
||||
|
||||
for (AbstractWidget widget : widgetsToRemove) {
|
||||
this.removeWidget(widget);
|
||||
}
|
||||
|
||||
this.helloworld$restoreMenuSpacing(singleplayerButton, multiplayerButton, optionsButton, quitButton);
|
||||
}
|
||||
|
||||
@ModifyArg(
|
||||
method = "extractRenderState",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Ljava/lang/String;III)V"
|
||||
),
|
||||
index = 1
|
||||
)
|
||||
private String helloworld$replaceVersionText(String original) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Inject(method = "extractRenderState", at = @At("TAIL"))
|
||||
private void helloworld$renderLicenseNotice(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta, CallbackInfo ci) {
|
||||
graphics.text(this.font, LICENSE_TEXT, 2, Math.max(2, this.height - 10), FOOTER_TEXT_COLOR);
|
||||
}
|
||||
|
||||
private void helloworld$restoreMenuSpacing(
|
||||
AbstractWidget singleplayerButton,
|
||||
AbstractWidget multiplayerButton,
|
||||
AbstractWidget optionsButton,
|
||||
AbstractWidget quitButton
|
||||
) {
|
||||
int centerX = this.width / 2;
|
||||
int baseY = this.height / 4 + 48;
|
||||
int optionsRowY = baseY + 24 + OPTIONS_SECTION_GAP;
|
||||
|
||||
singleplayerButton.setPosition(centerX - 100, baseY);
|
||||
multiplayerButton.setPosition(centerX - 100, baseY + 24);
|
||||
optionsButton.setPosition(centerX - 100, optionsRowY);
|
||||
quitButton.setPosition(centerX + 2, optionsRowY);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package fr.koka99cab.helloworld.client.mixin;
|
||||
|
||||
import fr.koka99cab.helloworld.client.title.CustomTitleIconClient;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(com.mojang.blaze3d.platform.Window.class)
|
||||
public abstract class WindowMixin {
|
||||
@Shadow
|
||||
public abstract long handle();
|
||||
|
||||
@Inject(method = "setTitle", at = @At("HEAD"), cancellable = true)
|
||||
private void helloworld$forceCustomTitle(String title, CallbackInfo callbackInfo) {
|
||||
GLFW.glfwSetWindowTitle(this.handle(), CustomTitleIconClient.windowTitle());
|
||||
callbackInfo.cancel();
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package fr.koka99cab.helloworld.client.rpc;
|
||||
|
||||
import fr.koka99cab.helloworld.HelloWorldMod;
|
||||
import fr.koka99cab.helloworld.client.rpc.config.SanctuaryRpcConfig;
|
||||
import fr.koka99cab.helloworld.client.rpc.discord.DiscordActivity;
|
||||
import fr.koka99cab.helloworld.client.rpc.discord.DiscordIpcClient;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
|
||||
|
||||
public final class SanctuaryDiscordPresence {
|
||||
private static DiscordIpcClient discordIpcClient;
|
||||
|
||||
private SanctuaryDiscordPresence() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
SanctuaryRpcConfig config = SanctuaryRpcConfig.load();
|
||||
if (!config.enabled()) {
|
||||
HelloWorldMod.LOGGER.info("Discord RPC Sanctuary desactive dans la configuration.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.hasClientId()) {
|
||||
HelloWorldMod.LOGGER.warn("Sanctuary RPC attend un clientId Discord dans config/sanctuary-rpc.properties.");
|
||||
return;
|
||||
}
|
||||
|
||||
discordIpcClient = new DiscordIpcClient(
|
||||
config.clientId(),
|
||||
DiscordActivity.fromConfig(config),
|
||||
HelloWorldMod.LOGGER
|
||||
);
|
||||
discordIpcClient.start();
|
||||
|
||||
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> stop());
|
||||
}
|
||||
|
||||
private static void stop() {
|
||||
if (discordIpcClient != null) {
|
||||
discordIpcClient.stop();
|
||||
discordIpcClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package fr.koka99cab.helloworld.client.rpc.config;
|
||||
|
||||
import fr.koka99cab.helloworld.HelloWorldMod;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
public record SanctuaryRpcConfig(
|
||||
boolean enabled,
|
||||
String clientId,
|
||||
String details,
|
||||
String state,
|
||||
String largeImage,
|
||||
String largeText,
|
||||
String smallImage,
|
||||
String smallText,
|
||||
boolean showElapsedTime
|
||||
) {
|
||||
private static final String CONFIG_FILE = "sanctuary-rpc.properties";
|
||||
private static final String DEFAULT_CLIENT_ID = "1522671140735025182";
|
||||
private static final String OLD_CLIENT_ID_PLACEHOLDER = "put-your-discord-application-id-here";
|
||||
|
||||
public static SanctuaryRpcConfig load() {
|
||||
Path path = FabricLoader.getInstance().getConfigDir().resolve(CONFIG_FILE);
|
||||
Properties properties = defaultProperties();
|
||||
boolean shouldSave = false;
|
||||
|
||||
try {
|
||||
if (Files.notExists(path)) {
|
||||
writeDefaults(path, properties);
|
||||
} else {
|
||||
try (Reader reader = Files.newBufferedReader(path)) {
|
||||
properties.load(reader);
|
||||
}
|
||||
|
||||
shouldSave = migrate(properties);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
HelloWorldMod.LOGGER.warn("Impossible de lire la configuration Sanctuary RPC, valeurs par defaut utilisees.", exception);
|
||||
}
|
||||
|
||||
if (shouldSave) {
|
||||
try {
|
||||
writeDefaults(path, properties);
|
||||
} catch (IOException exception) {
|
||||
HelloWorldMod.LOGGER.warn("Impossible de mettre a jour la configuration Sanctuary RPC.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
return new SanctuaryRpcConfig(
|
||||
Boolean.parseBoolean(properties.getProperty("enabled")),
|
||||
properties.getProperty("clientId", "").trim(),
|
||||
properties.getProperty("details", "").trim(),
|
||||
properties.getProperty("state", "").trim(),
|
||||
properties.getProperty("largeImage", "").trim(),
|
||||
properties.getProperty("largeText", "").trim(),
|
||||
properties.getProperty("smallImage", "").trim(),
|
||||
properties.getProperty("smallText", "").trim(),
|
||||
Boolean.parseBoolean(properties.getProperty("showElapsedTime"))
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasClientId() {
|
||||
return !clientId.isBlank();
|
||||
}
|
||||
|
||||
private static Properties defaultProperties() {
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("enabled", "true");
|
||||
properties.setProperty("clientId", DEFAULT_CLIENT_ID);
|
||||
properties.setProperty("details", "Joue a Sanctuary");
|
||||
properties.setProperty("state", "Minecraft 26.1.2 - Fabric");
|
||||
properties.setProperty("largeImage", "");
|
||||
properties.setProperty("largeText", "Sanctuary");
|
||||
properties.setProperty("smallImage", "");
|
||||
properties.setProperty("smallText", "");
|
||||
properties.setProperty("showElapsedTime", "true");
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static boolean migrate(Properties properties) {
|
||||
boolean changed = false;
|
||||
|
||||
String clientId = properties.getProperty("clientId", "").trim();
|
||||
if (clientId.isBlank() || OLD_CLIENT_ID_PLACEHOLDER.equals(clientId)) {
|
||||
properties.setProperty("clientId", DEFAULT_CLIENT_ID);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (String key : defaultProperties().stringPropertyNames()) {
|
||||
if (!properties.containsKey(key)) {
|
||||
properties.setProperty(key, defaultProperties().getProperty(key));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static void writeDefaults(Path path, Properties properties) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
|
||||
try (Writer writer = Files.newBufferedWriter(path)) {
|
||||
writer.write("# Sanctuary RPC\n");
|
||||
writer.write("# clientId doit etre l'identifiant d'une application Discord Developer Portal.\n");
|
||||
writer.write("# Pour afficher une image, ajoute un asset Rich Presence nomme comme largeImage.\n");
|
||||
properties.store(writer, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package fr.koka99cab.helloworld.client.rpc.discord;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import fr.koka99cab.helloworld.client.rpc.config.SanctuaryRpcConfig;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record DiscordActivity(
|
||||
String details,
|
||||
String state,
|
||||
String largeImage,
|
||||
String largeText,
|
||||
String smallImage,
|
||||
String smallText,
|
||||
boolean showElapsedTime,
|
||||
long startedAt
|
||||
) {
|
||||
public static DiscordActivity fromConfig(SanctuaryRpcConfig config) {
|
||||
return new DiscordActivity(
|
||||
config.details(),
|
||||
config.state(),
|
||||
config.largeImage(),
|
||||
config.largeText(),
|
||||
config.smallImage(),
|
||||
config.smallText(),
|
||||
config.showElapsedTime(),
|
||||
Instant.now().getEpochSecond()
|
||||
);
|
||||
}
|
||||
|
||||
public JsonObject toJson() {
|
||||
JsonObject activity = new JsonObject();
|
||||
putIfNotBlank(activity, "details", details);
|
||||
putIfNotBlank(activity, "state", state);
|
||||
|
||||
if (showElapsedTime) {
|
||||
JsonObject timestamps = new JsonObject();
|
||||
timestamps.addProperty("start", startedAt);
|
||||
activity.add("timestamps", timestamps);
|
||||
}
|
||||
|
||||
JsonObject assets = new JsonObject();
|
||||
putIfNotBlank(assets, "large_image", largeImage);
|
||||
putIfNotBlank(assets, "large_text", largeText);
|
||||
putIfNotBlank(assets, "small_image", smallImage);
|
||||
putIfNotBlank(assets, "small_text", smallText);
|
||||
|
||||
if (assets.size() > 0) {
|
||||
activity.add("assets", assets);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static void putIfNotBlank(JsonObject json, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
json.addProperty(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
package fr.koka99cab.helloworld.client.rpc.discord;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class DiscordIpcClient {
|
||||
private static final long RECONNECT_DELAY_MILLIS = 15_000L;
|
||||
private static final long ACTIVITY_REFRESH_MILLIS = 30_000L;
|
||||
|
||||
private final String clientId;
|
||||
private final DiscordActivity activity;
|
||||
private final Logger logger;
|
||||
private final AtomicBoolean running = new AtomicBoolean();
|
||||
|
||||
private volatile DiscordIpcConnection connection;
|
||||
private volatile String lastActivityNonce;
|
||||
private Thread worker;
|
||||
private boolean missingDiscordLogged;
|
||||
private boolean activityAcceptedLogged;
|
||||
|
||||
public DiscordIpcClient(String clientId, DiscordActivity activity, Logger logger) {
|
||||
this.clientId = clientId;
|
||||
this.activity = activity;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker = new Thread(this::run, "Sanctuary Discord RPC");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (!running.compareAndSet(true, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DiscordIpcConnection activeConnection = connection;
|
||||
if (activeConnection != null) {
|
||||
try {
|
||||
clearActivity(activeConnection);
|
||||
} catch (IOException exception) {
|
||||
logger.debug("Impossible d'effacer la presence Discord.", exception);
|
||||
}
|
||||
|
||||
try {
|
||||
activeConnection.close();
|
||||
} catch (IOException exception) {
|
||||
logger.debug("Impossible de fermer la connexion Discord IPC.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
Thread activeWorker = worker;
|
||||
if (activeWorker != null) {
|
||||
activeWorker.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void run() {
|
||||
while (running.get()) {
|
||||
try (DiscordIpcConnection activeConnection = DiscordIpcConnection.open()) {
|
||||
connection = activeConnection;
|
||||
handshake(activeConnection);
|
||||
setActivity(activeConnection);
|
||||
missingDiscordLogged = false;
|
||||
logger.info("Presence Discord Sanctuary active via {}.", activeConnection.endpoint());
|
||||
AtomicBoolean refreshRunning = new AtomicBoolean(true);
|
||||
Thread refreshWorker = startRefreshWorker(activeConnection, refreshRunning);
|
||||
try {
|
||||
readLoop(activeConnection);
|
||||
} finally {
|
||||
refreshRunning.set(false);
|
||||
refreshWorker.interrupt();
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
logConnectionFailure(exception);
|
||||
} finally {
|
||||
connection = null;
|
||||
}
|
||||
|
||||
sleepBeforeReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void handshake(DiscordIpcConnection activeConnection) throws IOException {
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("v", 1);
|
||||
payload.addProperty("client_id", clientId);
|
||||
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_HANDSHAKE, payload);
|
||||
}
|
||||
|
||||
private void setActivity(DiscordIpcConnection activeConnection) throws IOException {
|
||||
JsonObject args = new JsonObject();
|
||||
args.addProperty("pid", ProcessHandle.current().pid());
|
||||
args.add("activity", activity.toJson());
|
||||
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("cmd", "SET_ACTIVITY");
|
||||
payload.add("args", args);
|
||||
String nonce = UUID.randomUUID().toString();
|
||||
payload.addProperty("nonce", nonce);
|
||||
lastActivityNonce = nonce;
|
||||
|
||||
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_FRAME, payload);
|
||||
}
|
||||
|
||||
private void clearActivity(DiscordIpcConnection activeConnection) throws IOException {
|
||||
JsonObject args = new JsonObject();
|
||||
args.addProperty("pid", ProcessHandle.current().pid());
|
||||
args.add("activity", null);
|
||||
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("cmd", "SET_ACTIVITY");
|
||||
payload.add("args", args);
|
||||
payload.addProperty("nonce", UUID.randomUUID().toString());
|
||||
|
||||
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_FRAME, payload);
|
||||
}
|
||||
|
||||
private void readLoop(DiscordIpcConnection activeConnection) throws IOException {
|
||||
while (running.get()) {
|
||||
DiscordIpcConnection.IpcFrame frame = activeConnection.readFrame();
|
||||
if (frame == null || frame.opcode() == DiscordIpcConnection.OPCODE_CLOSE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode() == DiscordIpcConnection.OPCODE_PING) {
|
||||
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_PONG, frame.payload());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.opcode() == DiscordIpcConnection.OPCODE_FRAME) {
|
||||
handlePayload(frame.payload());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Thread startRefreshWorker(DiscordIpcConnection activeConnection, AtomicBoolean refreshRunning) {
|
||||
Thread refreshWorker = new Thread(() -> {
|
||||
while (running.get() && refreshRunning.get()) {
|
||||
try {
|
||||
Thread.sleep(ACTIVITY_REFRESH_MILLIS);
|
||||
if (running.get() && refreshRunning.get()) {
|
||||
setActivity(activeConnection);
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (IOException exception) {
|
||||
logger.debug("Impossible de rafraichir la presence Discord.", exception);
|
||||
refreshRunning.set(false);
|
||||
}
|
||||
}
|
||||
}, "Sanctuary Discord RPC refresh");
|
||||
refreshWorker.setDaemon(true);
|
||||
refreshWorker.start();
|
||||
return refreshWorker;
|
||||
}
|
||||
|
||||
private void handlePayload(String payload) {
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(payload).getAsJsonObject();
|
||||
String command = stringMember(json, "cmd");
|
||||
String event = stringMember(json, "evt");
|
||||
String nonce = stringMember(json, "nonce");
|
||||
|
||||
if ("ERROR".equals(command)) {
|
||||
JsonObject data = objectMember(json, "data");
|
||||
String message = data == null ? payload : stringMember(data, "message");
|
||||
logger.warn("Discord RPC a renvoye une erreur: {}", message == null ? payload : message);
|
||||
} else if ("DISPATCH".equals(command) && "READY".equals(event)) {
|
||||
logger.debug("Discord RPC pret.");
|
||||
} else if ("SET_ACTIVITY".equals(command) && nonce != null && nonce.equals(lastActivityNonce)) {
|
||||
logActivityAccepted(json);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.debug("Payload Discord RPC ignore.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void logActivityAccepted(JsonObject payload) {
|
||||
if (activityAcceptedLogged) {
|
||||
logger.debug("Discord RPC confirme le rafraichissement de l'activite.");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject data = objectMember(payload, "data");
|
||||
String name = data == null ? null : stringMember(data, "name");
|
||||
String details = data == null ? null : stringMember(data, "details");
|
||||
String state = data == null ? null : stringMember(data, "state");
|
||||
logger.info(
|
||||
"Discord RPC confirme l'activite: name={}, details={}, state={}.",
|
||||
name == null ? "?" : name,
|
||||
details == null ? "?" : details,
|
||||
state == null ? "?" : state
|
||||
);
|
||||
activityAcceptedLogged = true;
|
||||
}
|
||||
|
||||
private void logConnectionFailure(IOException exception) {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!missingDiscordLogged) {
|
||||
logger.warn(exception.getMessage());
|
||||
missingDiscordLogged = true;
|
||||
} else {
|
||||
logger.debug("Reconnexion Discord RPC en attente.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void sleepBeforeReconnect() {
|
||||
if (!running.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(RECONNECT_DELAY_MILLIS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject objectMember(JsonObject json, String memberName) {
|
||||
JsonElement element = json.get(memberName);
|
||||
return element != null && element.isJsonObject() ? element.getAsJsonObject() : null;
|
||||
}
|
||||
|
||||
private static String stringMember(JsonObject json, String memberName) {
|
||||
JsonElement element = json.get(memberName);
|
||||
return element != null && element.isJsonPrimitive() ? element.getAsString() : null;
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package fr.koka99cab.helloworld.client.rpc.discord;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.channels.ByteChannel;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
final class DiscordIpcConnection implements Closeable {
|
||||
static final int OPCODE_HANDSHAKE = 0;
|
||||
static final int OPCODE_FRAME = 1;
|
||||
static final int OPCODE_CLOSE = 2;
|
||||
static final int OPCODE_PING = 3;
|
||||
static final int OPCODE_PONG = 4;
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final int MAX_FRAME_SIZE = 1024 * 1024;
|
||||
|
||||
private final ByteChannel channel;
|
||||
private final String endpoint;
|
||||
|
||||
private DiscordIpcConnection(ByteChannel channel, String endpoint) {
|
||||
this.channel = channel;
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
static DiscordIpcConnection open() throws IOException {
|
||||
if (isWindows()) {
|
||||
return openWindowsPipe();
|
||||
}
|
||||
|
||||
return openUnixSocket();
|
||||
}
|
||||
|
||||
synchronized void writeFrame(int opcode, JsonObject payload) throws IOException {
|
||||
writeFrame(opcode, GSON.toJson(payload));
|
||||
}
|
||||
|
||||
synchronized void writeFrame(int opcode, String payload) throws IOException {
|
||||
byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8);
|
||||
ByteBuffer buffer = ByteBuffer.allocate(8 + payloadBytes.length).order(ByteOrder.LITTLE_ENDIAN);
|
||||
buffer.putInt(opcode);
|
||||
buffer.putInt(payloadBytes.length);
|
||||
buffer.put(payloadBytes);
|
||||
buffer.flip();
|
||||
writeFully(buffer);
|
||||
}
|
||||
|
||||
IpcFrame readFrame() throws IOException {
|
||||
ByteBuffer header = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
|
||||
if (!readFully(header)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
header.flip();
|
||||
int opcode = header.getInt();
|
||||
int length = header.getInt();
|
||||
if (length < 0 || length > MAX_FRAME_SIZE) {
|
||||
throw new IOException("Discord IPC frame invalide: " + length + " octets.");
|
||||
}
|
||||
|
||||
ByteBuffer payload = ByteBuffer.allocate(length);
|
||||
if (!readFully(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
payload.flip();
|
||||
return new IpcFrame(opcode, StandardCharsets.UTF_8.decode(payload).toString());
|
||||
}
|
||||
|
||||
String endpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
channel.close();
|
||||
}
|
||||
|
||||
private void writeFully(ByteBuffer buffer) throws IOException {
|
||||
while (buffer.hasRemaining()) {
|
||||
int written = channel.write(buffer);
|
||||
if (written == 0) {
|
||||
Thread.onSpinWait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean readFully(ByteBuffer buffer) throws IOException {
|
||||
while (buffer.hasRemaining()) {
|
||||
int read = channel.read(buffer);
|
||||
if (read < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (read == 0) {
|
||||
Thread.onSpinWait();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DiscordIpcConnection openUnixSocket() throws IOException {
|
||||
IOException lastException = null;
|
||||
|
||||
for (Path candidate : unixCandidates()) {
|
||||
if (!Files.exists(candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SocketChannel socket = null;
|
||||
try {
|
||||
socket = SocketChannel.open(StandardProtocolFamily.UNIX);
|
||||
socket.connect(UnixDomainSocketAddress.of(candidate));
|
||||
return new DiscordIpcConnection(socket, candidate.toString());
|
||||
} catch (IOException exception) {
|
||||
lastException = exception;
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw missingDiscordException(lastException);
|
||||
}
|
||||
|
||||
private static DiscordIpcConnection openWindowsPipe() throws IOException {
|
||||
IOException lastException = null;
|
||||
|
||||
for (int index = 0; index < 10; index++) {
|
||||
String endpoint = "\\\\.\\pipe\\discord-ipc-" + index;
|
||||
try {
|
||||
FileChannel channel = FileChannel.open(
|
||||
Path.of(endpoint),
|
||||
StandardOpenOption.READ,
|
||||
StandardOpenOption.WRITE
|
||||
);
|
||||
return new DiscordIpcConnection(channel, endpoint);
|
||||
} catch (IOException exception) {
|
||||
lastException = exception;
|
||||
}
|
||||
}
|
||||
|
||||
throw missingDiscordException(lastException);
|
||||
}
|
||||
|
||||
private static List<Path> unixCandidates() {
|
||||
List<Path> candidates = new ArrayList<>();
|
||||
for (Path runtimeDirectory : unixRuntimeDirectories()) {
|
||||
for (int index = 0; index < 10; index++) {
|
||||
candidates.add(runtimeDirectory.resolve("discord-ipc-" + index));
|
||||
candidates.add(runtimeDirectory.resolve("snap.discord/discord-ipc-" + index));
|
||||
candidates.add(runtimeDirectory.resolve("app/com.discordapp.Discord/discord-ipc-" + index));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static Set<Path> unixRuntimeDirectories() {
|
||||
Set<Path> directories = new LinkedHashSet<>();
|
||||
addEnvironmentPath(directories, "XDG_RUNTIME_DIR");
|
||||
addEnvironmentPath(directories, "TMPDIR");
|
||||
addEnvironmentPath(directories, "TMP");
|
||||
addEnvironmentPath(directories, "TEMP");
|
||||
directories.add(Path.of("/tmp"));
|
||||
return directories;
|
||||
}
|
||||
|
||||
private static void addEnvironmentPath(Set<Path> directories, String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value != null && !value.isBlank()) {
|
||||
directories.add(Path.of(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isWindows() {
|
||||
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
|
||||
}
|
||||
|
||||
private static IOException missingDiscordException(IOException cause) {
|
||||
IOException exception = new IOException("Aucune connexion Discord IPC trouvee. Lance Discord puis redemarre Minecraft.");
|
||||
if (cause != null) {
|
||||
exception.initCause(cause);
|
||||
}
|
||||
return exception;
|
||||
}
|
||||
|
||||
record IpcFrame(int opcode, String payload) {
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package fr.koka99cab.helloworld.client.title;
|
||||
|
||||
import com.mojang.blaze3d.platform.Window;
|
||||
import fr.koka99cab.helloworld.HelloWorldMod;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.Taskbar;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import javax.imageio.ImageIO;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
import org.lwjgl.glfw.GLFWImage;
|
||||
import org.lwjgl.system.MemoryStack;
|
||||
import org.lwjgl.system.MemoryUtil;
|
||||
|
||||
public final class CustomTitleIconClient {
|
||||
private static final String WINDOW_TITLE = "sanctuary";
|
||||
private static final String APPLICATION_ICON_RESOURCE = "/assets/helloworld/icon.png";
|
||||
private static final boolean IS_MAC_OS = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac");
|
||||
private static final String[] WINDOW_ICON_RESOURCES = {
|
||||
APPLICATION_ICON_RESOURCE
|
||||
};
|
||||
|
||||
private CustomTitleIconClient() {
|
||||
}
|
||||
|
||||
public static String windowTitle() {
|
||||
return WINDOW_TITLE;
|
||||
}
|
||||
|
||||
public static void apply(Window window) {
|
||||
applyApplicationIcon();
|
||||
window.setTitle(WINDOW_TITLE);
|
||||
|
||||
if (IS_MAC_OS) {
|
||||
HelloWorldMod.LOGGER.info("Skipping GLFW window icon on macOS because Cocoa windows do not support it.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<ByteBuffer> pixelBuffers = new ArrayList<>(WINDOW_ICON_RESOURCES.length);
|
||||
|
||||
try (MemoryStack stack = MemoryStack.stackPush()) {
|
||||
GLFWImage.Buffer iconBuffer = GLFWImage.malloc(WINDOW_ICON_RESOURCES.length, stack);
|
||||
|
||||
for (int i = 0; i < WINDOW_ICON_RESOURCES.length; i++) {
|
||||
IconData iconData = loadIcon(WINDOW_ICON_RESOURCES[i]);
|
||||
pixelBuffers.add(iconData.pixels());
|
||||
|
||||
iconBuffer.position(i);
|
||||
iconBuffer.width(iconData.width());
|
||||
iconBuffer.height(iconData.height());
|
||||
iconBuffer.pixels(iconData.pixels());
|
||||
}
|
||||
|
||||
iconBuffer.position(0);
|
||||
GLFW.glfwSetWindowIcon(window.handle(), iconBuffer);
|
||||
HelloWorldMod.LOGGER.info("Applied sanctuary window title and icon.");
|
||||
} catch (IOException exception) {
|
||||
HelloWorldMod.LOGGER.error("Failed to load the sanctuary window icon.", exception);
|
||||
} finally {
|
||||
pixelBuffers.forEach(MemoryUtil::memFree);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyApplicationIcon() {
|
||||
try {
|
||||
BufferedImage iconImage = loadBufferedImage(APPLICATION_ICON_RESOURCE);
|
||||
applyTaskbarIcon(iconImage);
|
||||
|
||||
if (canUseAppleDockApi()) {
|
||||
EventQueue.invokeLater(() -> applyAppleDockIcon(iconImage));
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
HelloWorldMod.LOGGER.warn("Unable to load the sanctuary application icon.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean canUseAppleDockApi() {
|
||||
if (!IS_MAC_OS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ModuleLayer.boot()
|
||||
.findModule("java.desktop")
|
||||
.map(module -> module.isExported("com.apple.eawt", CustomTitleIconClient.class.getModule()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private static void applyTaskbarIcon(BufferedImage iconImage) {
|
||||
try {
|
||||
if (!Taskbar.isTaskbarSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Taskbar taskbar = Taskbar.getTaskbar();
|
||||
if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
taskbar.setIconImage(iconImage);
|
||||
HelloWorldMod.LOGGER.info("Applied sanctuary taskbar or Dock icon.");
|
||||
} catch (RuntimeException exception) {
|
||||
HelloWorldMod.LOGGER.warn("Unable to apply the taskbar or Dock icon.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyAppleDockIcon(BufferedImage iconImage) {
|
||||
try {
|
||||
Class<?> applicationClass = Class.forName("com.apple.eawt.Application");
|
||||
Object application = applicationClass.getMethod("getApplication").invoke(null);
|
||||
applicationClass.getMethod("setDockIconImage", java.awt.Image.class).invoke(application, iconImage);
|
||||
HelloWorldMod.LOGGER.info("Applied sanctuary Dock icon through com.apple.eawt.Application.");
|
||||
} catch (ReflectiveOperationException | RuntimeException exception) {
|
||||
HelloWorldMod.LOGGER.warn("Unable to apply the Dock icon through com.apple.eawt.Application.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static IconData loadIcon(String resourcePath) throws IOException {
|
||||
return toIconData(loadBufferedImage(resourcePath));
|
||||
}
|
||||
|
||||
private static BufferedImage loadBufferedImage(String resourcePath) throws IOException {
|
||||
try (InputStream inputStream = CustomTitleIconClient.class.getResourceAsStream(resourcePath)) {
|
||||
if (inputStream == null) {
|
||||
throw new IOException("Missing icon resource: " + resourcePath);
|
||||
}
|
||||
|
||||
BufferedImage image = ImageIO.read(inputStream);
|
||||
if (image == null) {
|
||||
throw new IOException("Unable to decode image: " + resourcePath);
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
private static IconData toIconData(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
ByteBuffer pixelBuffer = MemoryUtil.memAlloc(width * height * 4);
|
||||
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
|
||||
for (int pixel : pixels) {
|
||||
pixelBuffer.put((byte) (pixel >> 16 & 0xFF));
|
||||
pixelBuffer.put((byte) (pixel >> 8 & 0xFF));
|
||||
pixelBuffer.put((byte) (pixel & 0xFF));
|
||||
pixelBuffer.put((byte) (pixel >> 24 & 0xFF));
|
||||
}
|
||||
|
||||
pixelBuffer.flip();
|
||||
return new IconData(width, height, pixelBuffer);
|
||||
}
|
||||
|
||||
private record IconData(int width, int height, ByteBuffer pixels) {
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"required": true,
|
||||
"package": "fr.koka99cab.helloworld.client.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"client": [
|
||||
"LoadingOverlayMixin",
|
||||
"MinecraftClientMixin",
|
||||
"OnlineServerEntryMixin",
|
||||
"PauseScreenMixin",
|
||||
"TitleScreenMixin",
|
||||
"WindowMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
},
|
||||
"overwrites": {
|
||||
"requireAnnotations": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package fr.koka99cab.helloworld;
|
||||
|
||||
import fr.koka99cab.helloworld.motd.MotdPlusPlusMod;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class HelloWorldMod implements ModInitializer {
|
||||
public static final String MOD_ID = "helloworld";
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
MotdPlusPlusMod.initialize();
|
||||
LOGGER.info("helloworld charge pour Minecraft 26.1.2.");
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.koka99cab.helloworld.mixin;
|
||||
|
||||
import fr.koka99cab.helloworld.motd.MotdPlusPlusMod;
|
||||
import net.minecraft.network.protocol.status.ServerStatus;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(MinecraftServer.class)
|
||||
public abstract class MinecraftServerStatusMixin {
|
||||
@Inject(method = "getStatus", at = @At("RETURN"), cancellable = true)
|
||||
private void motdpp$replaceStatusSnapshot(CallbackInfoReturnable<ServerStatus> cir) {
|
||||
MinecraftServer server = (MinecraftServer) (Object) this;
|
||||
cir.setReturnValue(MotdPlusPlusMod.CONTROLLER.dynamicStatus(server, cir.getReturnValue()));
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package fr.koka99cab.helloworld.motd;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka99cab.helloworld.motd.config.MotdppConfig;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
public final class AnimationPayload {
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.create();
|
||||
private static final String PREFIX = "helloworld_motd:";
|
||||
private static final int MAX_INSERTION_CHARS = 20_000;
|
||||
|
||||
public String motdMode;
|
||||
public String scrollText;
|
||||
public String scrollPadding;
|
||||
public int scrollWindowWidth;
|
||||
public int motdSpeedTicks;
|
||||
public List<String> motdFrames;
|
||||
public String gifBase64;
|
||||
public long tickOffset;
|
||||
|
||||
public static String encode(MotdppConfig config, byte[] gifBytes, long tickOffset, Logger logger) {
|
||||
AnimationPayload payload = new AnimationPayload();
|
||||
payload.motdMode = config.normalizedMode();
|
||||
payload.scrollText = config.normalizedScrollText();
|
||||
payload.scrollPadding = config.normalizedScrollPadding();
|
||||
payload.scrollWindowWidth = config.normalizedScrollWindowWidth();
|
||||
payload.motdSpeedTicks = config.normalizedMotdSpeedTicks();
|
||||
payload.motdFrames = config.normalizedFrames();
|
||||
payload.tickOffset = tickOffset;
|
||||
payload.gifBase64 = gifBytes == null ? null : Base64.getEncoder().encodeToString(gifBytes);
|
||||
|
||||
String encoded = encodePayload(payload);
|
||||
|
||||
if (encoded.length() <= MAX_INSERTION_CHARS) {
|
||||
return encoded;
|
||||
}
|
||||
|
||||
if (payload.gifBase64 != null) {
|
||||
payload.gifBase64 = null;
|
||||
encoded = encodePayload(payload);
|
||||
|
||||
if (encoded.length() <= MAX_INSERTION_CHARS) {
|
||||
logger.warn("motd++: payload GIF trop gros pour le ping status, animation d'icone client desactivee");
|
||||
return encoded;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn("motd++: payload MOTD trop gros pour le ping status, animation client desactivee");
|
||||
return null;
|
||||
}
|
||||
|
||||
public static AnimationPayload decode(String insertion, Logger logger) {
|
||||
if (insertion == null || !insertion.startsWith(PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] compressed = Base64.getDecoder().decode(insertion.substring(PREFIX.length()));
|
||||
|
||||
try (GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
|
||||
String json = new String(gzipInputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
AnimationPayload payload = GSON.fromJson(json, AnimationPayload.class);
|
||||
return payload == null ? null : payload;
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
logger.warn("motd++: impossible de decoder le payload client", exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] gifBytes() {
|
||||
if (this.gifBase64 == null || this.gifBase64.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Base64.getDecoder().decode(this.gifBase64);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String normalizedMode() {
|
||||
return Objects.equals(this.motdMode, "frames") ? "frames" : "scroll";
|
||||
}
|
||||
|
||||
public int normalizedMotdSpeedTicks() {
|
||||
return Math.max(1, this.motdSpeedTicks);
|
||||
}
|
||||
|
||||
private static String encodePayload(AnimationPayload payload) {
|
||||
try (ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
|
||||
GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteStream)) {
|
||||
gzipOutputStream.write(GSON.toJson(payload).getBytes(StandardCharsets.UTF_8));
|
||||
gzipOutputStream.finish();
|
||||
return PREFIX + Base64.getEncoder().encodeToString(byteStream.toByteArray());
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Impossible d'encoder le payload motd++", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package fr.koka99cab.helloworld.motd;
|
||||
|
||||
public record GifFrameData(byte[] pngBytes, int durationTicks) {
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package fr.koka99cab.helloworld.motd;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.metadata.IIOMetadata;
|
||||
import javax.imageio.metadata.IIOMetadataNode;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
public final class GifIconLoader {
|
||||
private static final int ICON_SIZE = 64;
|
||||
|
||||
private GifIconLoader() {
|
||||
}
|
||||
|
||||
public static byte[] readGifBytes(Path gifPath, Logger logger) {
|
||||
if (!Files.isRegularFile(gifPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Files.readAllBytes(gifPath);
|
||||
} catch (IOException exception) {
|
||||
logger.error("motd++: echec de lecture du GIF {}", gifPath, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<GifFrameData> load(Path gifPath, Logger logger) {
|
||||
if (!Files.isRegularFile(gifPath)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try (InputStream inputStream = Files.newInputStream(gifPath)) {
|
||||
return load(inputStream, gifPath.getFileName().toString(), logger);
|
||||
} catch (IOException exception) {
|
||||
logger.error("motd++: echec du chargement de {}", gifPath, exception);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<GifFrameData> load(byte[] gifBytes, Logger logger) {
|
||||
if (gifBytes == null || gifBytes.length == 0) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try (InputStream inputStream = new ByteArrayInputStream(gifBytes)) {
|
||||
return load(inputStream, "payload GIF", logger);
|
||||
} catch (IOException exception) {
|
||||
logger.error("motd++: echec du chargement du GIF embarque", exception);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<GifFrameData> load(InputStream inputStream, String sourceName, Logger logger) throws IOException {
|
||||
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(inputStream)) {
|
||||
if (imageInputStream == null) {
|
||||
logger.warn("motd++: impossible de lire {}", sourceName);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("gif");
|
||||
|
||||
if (!readers.hasNext()) {
|
||||
logger.warn("motd++: aucun reader GIF disponible pour {}", sourceName);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
ImageReader reader = readers.next();
|
||||
|
||||
try {
|
||||
reader.setInput(imageInputStream, false, false);
|
||||
int frameCount = reader.getNumImages(true);
|
||||
List<GifFrameData> frames = new ArrayList<>(frameCount);
|
||||
|
||||
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++) {
|
||||
BufferedImage rawFrame = reader.read(frameIndex);
|
||||
BufferedImage normalizedFrame = scaleToIcon(rawFrame);
|
||||
int durationTicks = readFrameDurationTicks(reader.getImageMetadata(frameIndex));
|
||||
frames.add(new GifFrameData(writePng(normalizedFrame), durationTicks));
|
||||
}
|
||||
|
||||
logger.info("motd++: {} frame(s) GIF chargee(s) depuis {}", frames.size(), sourceName);
|
||||
return frames;
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static BufferedImage scaleToIcon(BufferedImage source) {
|
||||
if (source.getWidth() == ICON_SIZE && source.getHeight() == ICON_SIZE && source.getType() == BufferedImage.TYPE_INT_ARGB) {
|
||||
return source;
|
||||
}
|
||||
|
||||
BufferedImage target = new BufferedImage(ICON_SIZE, ICON_SIZE, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D graphics = target.createGraphics();
|
||||
|
||||
try {
|
||||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
graphics.drawImage(source, 0, 0, ICON_SIZE, ICON_SIZE, null);
|
||||
} finally {
|
||||
graphics.dispose();
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private static int readFrameDurationTicks(IIOMetadata metadata) {
|
||||
if (metadata == null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
String nativeFormat = metadata.getNativeMetadataFormatName();
|
||||
|
||||
if (nativeFormat == null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree(nativeFormat);
|
||||
NodeList graphicControls = root.getElementsByTagName("GraphicControlExtension");
|
||||
|
||||
if (graphicControls.getLength() == 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
IIOMetadataNode graphicControl = (IIOMetadataNode) graphicControls.item(0);
|
||||
String delayTime = graphicControl.getAttribute("delayTime");
|
||||
|
||||
try {
|
||||
int hundredths = Integer.parseInt(delayTime);
|
||||
return Math.max(1, Math.round(hundredths / 5.0f));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] writePng(BufferedImage image) throws IOException {
|
||||
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
if (!ImageIO.write(image, "PNG", outputStream)) {
|
||||
throw new IOException("Aucun writer PNG disponible");
|
||||
}
|
||||
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package fr.koka99cab.helloworld.motd;
|
||||
|
||||
import fr.koka99cab.helloworld.HelloWorldMod;
|
||||
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public final class MotdPlusPlusMod {
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(HelloWorldMod.MOD_ID + "/motdpp");
|
||||
public static final MotdStatusController CONTROLLER = new MotdStatusController();
|
||||
private static boolean initialized;
|
||||
|
||||
private MotdPlusPlusMod() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
ServerLifecycleEvents.SERVER_STARTED.register(CONTROLLER::start);
|
||||
ServerLifecycleEvents.SERVER_STOPPING.register(CONTROLLER::stop);
|
||||
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) ->
|
||||
dispatcher.register(
|
||||
Commands.literal("motdpp")
|
||||
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
|
||||
.then(Commands.literal("reload")
|
||||
.executes(context -> {
|
||||
CONTROLLER.reload(context.getSource().getServer());
|
||||
context.getSource().sendSuccess(() -> Component.literal("sanctuary MOTD++ config rechargee."), true);
|
||||
return 1;
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package fr.koka99cab.helloworld.motd;
|
||||
|
||||
import fr.koka99cab.helloworld.motd.config.MotdppConfig;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.MutableComponent;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.network.protocol.status.ServerStatus;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class MotdStatusController {
|
||||
private static final long NANOS_PER_TICK = 50_000_000L;
|
||||
|
||||
private final Map<MinecraftServer, ServerAnimationState> states = new IdentityHashMap<>();
|
||||
|
||||
public void start(MinecraftServer server) {
|
||||
ServerStatus currentStatus = server.getStatus();
|
||||
ServerStatus.Favicon fallbackIcon = currentStatus == null ? null : currentStatus.favicon().orElse(null);
|
||||
this.states.put(server, ServerAnimationState.load(server, fallbackIcon));
|
||||
}
|
||||
|
||||
public void stop(MinecraftServer server) {
|
||||
this.states.remove(server);
|
||||
}
|
||||
|
||||
public void reload(MinecraftServer server) {
|
||||
ServerAnimationState currentState = this.states.get(server);
|
||||
ServerStatus currentStatus = server.getStatus();
|
||||
ServerStatus.Favicon fallbackIcon = currentState != null
|
||||
? currentState.fallbackIcon()
|
||||
: currentStatus == null ? null : currentStatus.favicon().orElse(null);
|
||||
this.states.put(server, ServerAnimationState.load(server, fallbackIcon));
|
||||
}
|
||||
|
||||
public ServerStatus dynamicStatus(MinecraftServer server, ServerStatus baseStatus) {
|
||||
ServerAnimationState state = this.states.get(server);
|
||||
|
||||
if (state == null || baseStatus == null) {
|
||||
return baseStatus;
|
||||
}
|
||||
|
||||
return state.createStatus(baseStatus, System.nanoTime());
|
||||
}
|
||||
|
||||
private record ServerAnimationState(
|
||||
MotdppConfig config,
|
||||
MotdAnimator motdAnimator,
|
||||
IconAnimator iconAnimator,
|
||||
ServerStatus.Favicon fallbackIcon,
|
||||
byte[] rawGifBytes,
|
||||
long startedAtNanos
|
||||
) {
|
||||
private static final String GIF_FILE_NAME = "server-icon.gif";
|
||||
|
||||
private static ServerAnimationState load(MinecraftServer server, ServerStatus.Favicon fallbackIcon) {
|
||||
Path configPath = FabricLoader.getInstance().getConfigDir().resolve("motdpp.json");
|
||||
MotdppConfig config = MotdppConfig.load(configPath, MotdPlusPlusMod.LOGGER);
|
||||
Path gifPath = server.getFile(GIF_FILE_NAME);
|
||||
List<IconAnimator.IconFrame> iconFrames = GifIconLoader.load(gifPath, MotdPlusPlusMod.LOGGER).stream()
|
||||
.map(frame -> new IconAnimator.IconFrame(new ServerStatus.Favicon(frame.pngBytes()), frame.durationTicks()))
|
||||
.toList();
|
||||
return new ServerAnimationState(
|
||||
config,
|
||||
MotdAnimator.create(config),
|
||||
IconAnimator.create(iconFrames),
|
||||
fallbackIcon,
|
||||
GifIconLoader.readGifBytes(gifPath, MotdPlusPlusMod.LOGGER),
|
||||
System.nanoTime()
|
||||
);
|
||||
}
|
||||
|
||||
private ServerStatus createStatus(ServerStatus baseStatus, long nowNanos) {
|
||||
long elapsedTicks = Math.max(0L, (nowNanos - this.startedAtNanos) / NANOS_PER_TICK);
|
||||
String motd = this.motdAnimator.frameAt(elapsedTicks);
|
||||
ServerStatus.Favicon favicon = this.iconAnimator.faviconAt(elapsedTicks, this.fallbackIcon);
|
||||
String insertion = AnimationPayload.encode(this.config, this.rawGifBytes, elapsedTicks, MotdPlusPlusMod.LOGGER);
|
||||
MutableComponent description = Component.literal(motd);
|
||||
|
||||
if (insertion != null) {
|
||||
description = description.withStyle(Style.EMPTY.withInsertion(insertion));
|
||||
}
|
||||
|
||||
return new ServerStatus(
|
||||
description,
|
||||
baseStatus.players(),
|
||||
baseStatus.version(),
|
||||
Optional.ofNullable(favicon),
|
||||
baseStatus.enforcesSecureChat()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MotdAnimator {
|
||||
private final String mode;
|
||||
private final int speedTicks;
|
||||
private final List<String> frameSequence;
|
||||
private final int[] scrollCodePoints;
|
||||
private final int scrollWindowWidth;
|
||||
|
||||
private MotdAnimator(String mode, int speedTicks, List<String> frameSequence, int[] scrollCodePoints, int scrollWindowWidth) {
|
||||
this.mode = mode;
|
||||
this.speedTicks = speedTicks;
|
||||
this.frameSequence = frameSequence;
|
||||
this.scrollCodePoints = scrollCodePoints;
|
||||
this.scrollWindowWidth = scrollWindowWidth;
|
||||
}
|
||||
|
||||
private static MotdAnimator create(MotdppConfig config) {
|
||||
List<String> frames = config.normalizedFrames();
|
||||
String scrollText = config.normalizedScrollText();
|
||||
|
||||
if ("frames".equals(config.normalizedMode()) || (scrollText.isBlank() && !frames.isEmpty())) {
|
||||
return new MotdAnimator(
|
||||
"frames",
|
||||
config.normalizedMotdSpeedTicks(),
|
||||
frames,
|
||||
new int[0],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (scrollText.isBlank()) {
|
||||
scrollText = String.join(" | ", frames);
|
||||
}
|
||||
|
||||
String scrollSource = scrollText + config.normalizedScrollPadding();
|
||||
return new MotdAnimator(
|
||||
"scroll",
|
||||
config.normalizedMotdSpeedTicks(),
|
||||
frames,
|
||||
scrollSource.codePoints().toArray(),
|
||||
config.normalizedScrollWindowWidth()
|
||||
);
|
||||
}
|
||||
|
||||
private String frameAt(long elapsedTicks) {
|
||||
if ("frames".equals(this.mode)) {
|
||||
long frameStep = elapsedTicks / this.speedTicks;
|
||||
int frameIndex = (int) (frameStep % this.frameSequence.size());
|
||||
String frame = this.frameSequence.get(frameIndex);
|
||||
return frame.isBlank() ? "motd++" : frame;
|
||||
}
|
||||
|
||||
if (this.scrollCodePoints.length == 0) {
|
||||
return this.frameSequence.isEmpty() ? "motd++" : this.frameSequence.getFirst();
|
||||
}
|
||||
|
||||
long scrollStep = elapsedTicks / this.speedTicks;
|
||||
int offset = (int) (scrollStep % this.scrollCodePoints.length);
|
||||
String frame = buildScrollFrame(offset);
|
||||
return frame.isBlank() && !this.frameSequence.isEmpty() ? this.frameSequence.getFirst() : frame;
|
||||
}
|
||||
|
||||
private String buildScrollFrame(int offset) {
|
||||
if (this.scrollCodePoints.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
for (int index = 0; index < this.scrollWindowWidth; index++) {
|
||||
int codePoint = this.scrollCodePoints[(offset + index) % this.scrollCodePoints.length];
|
||||
builder.appendCodePoint(codePoint);
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
static final class IconAnimator {
|
||||
private final List<IconFrame> frames;
|
||||
private final long cycleDurationTicks;
|
||||
|
||||
private IconAnimator(List<IconFrame> frames) {
|
||||
this.frames = frames;
|
||||
this.cycleDurationTicks = frames.stream()
|
||||
.mapToLong(IconFrame::durationTicks)
|
||||
.sum();
|
||||
}
|
||||
|
||||
private static IconAnimator create(List<IconFrame> frames) {
|
||||
return new IconAnimator(frames);
|
||||
}
|
||||
|
||||
private ServerStatus.Favicon faviconAt(long elapsedTicks, ServerStatus.Favicon fallbackIcon) {
|
||||
if (this.frames.isEmpty()) {
|
||||
return fallbackIcon;
|
||||
}
|
||||
|
||||
if (this.frames.size() == 1 || this.cycleDurationTicks <= 0L) {
|
||||
return this.frames.getFirst().favicon();
|
||||
}
|
||||
|
||||
long tickInCycle = elapsedTicks % this.cycleDurationTicks;
|
||||
long cursor = 0L;
|
||||
|
||||
for (IconFrame frame : this.frames) {
|
||||
cursor += frame.durationTicks();
|
||||
|
||||
if (tickInCycle < cursor) {
|
||||
return frame.favicon();
|
||||
}
|
||||
}
|
||||
|
||||
return this.frames.getLast().favicon();
|
||||
}
|
||||
|
||||
record IconFrame(ServerStatus.Favicon favicon, int durationTicks) {
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package fr.koka99cab.helloworld.motd.config;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class MotdppConfig {
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.disableHtmlEscaping()
|
||||
.create();
|
||||
|
||||
public String motdMode = "scroll";
|
||||
public String scrollText = "Bienvenue sur sanctuary | pose server-icon.gif a la racine du serveur";
|
||||
public String scrollPadding = " ";
|
||||
public int scrollWindowWidth = 48;
|
||||
public int motdSpeedTicks = 1;
|
||||
public boolean clientAutoRefresh = false;
|
||||
public int clientRefreshTicks = 10;
|
||||
public List<String> motdFrames = new ArrayList<>(List.of(
|
||||
"sanctuary est pret",
|
||||
"Ajoute server-icon.gif",
|
||||
"Edite config/motdpp.json"
|
||||
));
|
||||
|
||||
public static MotdppConfig load(Path path, Logger logger) {
|
||||
MotdppConfig defaults = new MotdppConfig();
|
||||
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
|
||||
if (Files.notExists(path)) {
|
||||
save(path, defaults);
|
||||
logger.info("motd++: config creee dans {}", path);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try (Reader reader = Files.newBufferedReader(path)) {
|
||||
MotdppConfig loaded = GSON.fromJson(reader, MotdppConfig.class);
|
||||
|
||||
if (loaded == null) {
|
||||
save(path, defaults);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return sanitize(loaded);
|
||||
}
|
||||
} catch (IOException | JsonParseException exception) {
|
||||
logger.error("motd++: impossible de charger la config {}, valeurs par defaut utilisees", path, exception);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
public String normalizedMode() {
|
||||
String mode = Objects.requireNonNullElse(this.motdMode, "scroll");
|
||||
return switch (mode.toLowerCase(Locale.ROOT)) {
|
||||
case "frames" -> "frames";
|
||||
default -> "scroll";
|
||||
};
|
||||
}
|
||||
|
||||
public String normalizedScrollText() {
|
||||
return Objects.requireNonNullElse(this.scrollText, "");
|
||||
}
|
||||
|
||||
public String normalizedScrollPadding() {
|
||||
return Objects.requireNonNullElse(this.scrollPadding, " ");
|
||||
}
|
||||
|
||||
public int normalizedScrollWindowWidth() {
|
||||
return Math.max(1, this.scrollWindowWidth);
|
||||
}
|
||||
|
||||
public int normalizedMotdSpeedTicks() {
|
||||
return Math.max(1, this.motdSpeedTicks);
|
||||
}
|
||||
|
||||
public boolean normalizedClientAutoRefresh() {
|
||||
return this.clientAutoRefresh;
|
||||
}
|
||||
|
||||
public int normalizedClientRefreshTicks() {
|
||||
return Math.max(1, this.clientRefreshTicks);
|
||||
}
|
||||
|
||||
public List<String> normalizedFrames() {
|
||||
List<String> frames = this.motdFrames == null ? List.of() : this.motdFrames.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::strip)
|
||||
.filter(frame -> !frame.isEmpty())
|
||||
.toList();
|
||||
|
||||
if (!frames.isEmpty()) {
|
||||
return frames;
|
||||
}
|
||||
|
||||
return List.of("sanctuary");
|
||||
}
|
||||
|
||||
private static MotdppConfig sanitize(MotdppConfig config) {
|
||||
MotdppConfig sanitized = new MotdppConfig();
|
||||
sanitized.motdMode = config.normalizedMode();
|
||||
sanitized.scrollText = config.normalizedScrollText();
|
||||
sanitized.scrollPadding = config.normalizedScrollPadding();
|
||||
sanitized.scrollWindowWidth = config.normalizedScrollWindowWidth();
|
||||
sanitized.motdSpeedTicks = config.normalizedMotdSpeedTicks();
|
||||
sanitized.clientAutoRefresh = config.normalizedClientAutoRefresh();
|
||||
sanitized.clientRefreshTicks = config.normalizedClientRefreshTicks();
|
||||
sanitized.motdFrames = new ArrayList<>(config.normalizedFrames());
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private static void save(Path path, MotdppConfig config) throws IOException {
|
||||
try (Writer writer = Files.newBufferedWriter(path)) {
|
||||
GSON.toJson(config, writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"modmenu.nameTranslation.helloworld": "helloworld",
|
||||
"modmenu.descriptionTranslation.helloworld": "Custom loading screen, menu, title icon and MOTD++."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"modmenu.nameTranslation.helloworld": "helloworld",
|
||||
"modmenu.descriptionTranslation.helloworld": "Ecran de chargement, menu, icone de titre et MOTD++ personnalises."
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "helloworld",
|
||||
"version": "${version}",
|
||||
"name": "helloworld",
|
||||
"description": "Adds a custom loading screen, main menu, title icon, and enhanced MOTD for the sanctuary instance.",
|
||||
"icon": "assets/helloworld/icon.png",
|
||||
"authors": [
|
||||
"KOKA99CAB"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"environment": "*",
|
||||
"entrypoints": {
|
||||
"main": [
|
||||
"fr.koka99cab.helloworld.HelloWorldMod"
|
||||
],
|
||||
"client": [
|
||||
"fr.koka99cab.helloworld.client.HelloWorldClient"
|
||||
]
|
||||
},
|
||||
"mixins": [
|
||||
"helloworld.mixins.json",
|
||||
{
|
||||
"config": "helloworld.client.mixins.json",
|
||||
"environment": "client"
|
||||
}
|
||||
],
|
||||
"depends": {
|
||||
"fabricloader": ">=0.19.2",
|
||||
"minecraft": "~26.1.2",
|
||||
"java": ">=25",
|
||||
"fabric-api": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"required": true,
|
||||
"package": "fr.koka99cab.helloworld.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"mixins": [
|
||||
"MinecraftServerStatusMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user