Initial sanctuary sources
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
out/
|
||||
classes/
|
||||
|
||||
# Eclipse
|
||||
*.launch
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# VS Code
|
||||
.settings/
|
||||
.vscode/
|
||||
bin/
|
||||
.classpath
|
||||
.project
|
||||
|
||||
# macOS
|
||||
*.DS_Store
|
||||
|
||||
# Fabric
|
||||
run/
|
||||
|
||||
# Java
|
||||
.jdk/
|
||||
hs_err_*.log
|
||||
replay_*.log
|
||||
*.hprof
|
||||
*.jfr
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,67 @@
|
||||
plugins {
|
||||
id "net.fabricmc.fabric-loom" version "${loom_version}"
|
||||
id "maven-publish"
|
||||
}
|
||||
|
||||
version = project.mod_version
|
||||
group = project.maven_group
|
||||
|
||||
repositories {
|
||||
}
|
||||
|
||||
loom {
|
||||
splitEnvironmentSourceSets()
|
||||
|
||||
mods {
|
||||
"helloworld" {
|
||||
sourceSet sourceSets.main
|
||||
sourceSet sourceSets.client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||
}
|
||||
|
||||
processResources {
|
||||
def version = project.version
|
||||
inputs.property "version", version
|
||||
|
||||
filesMatching("fabric.mod.json") {
|
||||
expand "version": version
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
it.options.release = 25
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
jar {
|
||||
def projectName = project.name
|
||||
inputs.property "projectName", projectName
|
||||
|
||||
from("../../license.txt") {
|
||||
rename { "${it}_${projectName}" }
|
||||
}
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create("mavenJava", MavenPublication) {
|
||||
from components.java
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Done to increase the memory available to Gradle.
|
||||
org.gradle.jvmargs=-Xmx1G
|
||||
org.gradle.parallel=true
|
||||
|
||||
# IntelliJ IDEA is not yet fully compatible with configuration cache:
|
||||
# https://github.com/FabricMC/fabric-loom/issues/1349
|
||||
org.gradle.configuration-cache=false
|
||||
|
||||
# Fabric properties
|
||||
# Check current versions on https://fabricmc.net/develop
|
||||
minecraft_version=26.1.2
|
||||
loader_version=0.19.2
|
||||
loom_version=1.16-SNAPSHOT
|
||||
|
||||
# Mod properties
|
||||
mod_version=1.0.0
|
||||
maven_group=fr.koka99cab
|
||||
archives_base_name=helloworld
|
||||
|
||||
# Dependencies
|
||||
fabric_api_version=0.148.0+26.1.2
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
if [ -z "$JAVA_HOME" ] && [ -x "$APP_HOME/.jdk/current/bin/java" ] ; then
|
||||
JAVA_HOME=$APP_HOME/.jdk/current
|
||||
export JAVA_HOME
|
||||
fi
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,12 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven {
|
||||
name = "Fabric"
|
||||
url = "https://maven.fabricmc.net/"
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "helloworld"
|
||||
+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