commit b9a7ccf4be18cd9a4999bddb7f8813e9006a9d96 Author: Anatoly Kopyl Date: Fri Apr 10 01:11:20 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f22a44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Gradle +.gradle/ +build/ +out/ +classes/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ + +# OS +.DS_Store + +# Fabric / Loom +run/ +remappedSrc/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..10e431b --- /dev/null +++ b/build.gradle @@ -0,0 +1,56 @@ +plugins { + id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = 'respawn-backoff' +} + +repositories { +} + +loom { + splitEnvironmentSourceSets() + + mods { + "respawn_backoff" { + sourceSet sourceSets.main + sourceSet sourceSets.client + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" +} + +processResources { + inputs.property "version", project.version + + filesMatching("fabric.mod.json") { + expand "version": inputs.properties.version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +jar { + from("LICENSE") { + rename { "${it}_${base.archivesName.get()}" } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..26708a9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,12 @@ +org.gradle.jvmargs=-Xmx1G +org.gradle.parallel=true +org.gradle.configuration-cache=false + +minecraft_version=1.21.1 +loader_version=0.18.5 +loom_version=1.15-SNAPSHOT + +mod_version=1.0.0 +maven_group=net.respawnbackoff + +fabric_api_version=0.116.10+1.21.1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..321d68d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +networkTimeout=120000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..5b1fb11 --- /dev/null +++ b/gradlew @@ -0,0 +1,111 @@ +#!/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 +# + +############################################################################## + +app_path=$0 + +while + APP_HOME=${app_path%"${app_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 + +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +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 + +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + 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" + 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." + fi +fi + +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in + '' | soft) ;; + *) + ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + JAVACMD=$( cygpath --unix "$JAVACMD" ) +fi + +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..832faeb --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'respawn-backoff' diff --git a/src/client/java/net/respawnbackoff/client/RespawnBackoffClient.java b/src/client/java/net/respawnbackoff/client/RespawnBackoffClient.java new file mode 100644 index 0000000..646b781 --- /dev/null +++ b/src/client/java/net/respawnbackoff/client/RespawnBackoffClient.java @@ -0,0 +1,66 @@ +package net.respawnbackoff.client; + +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.respawnbackoff.CooldownSyncPayload; + +public class RespawnBackoffClient implements ClientModInitializer { + private static volatile boolean overlayActive; + private static volatile long cooldownEndEpochMs; + + @Override + public void onInitializeClient() { + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> clearForDisconnect()); + + ClientPlayNetworking.registerGlobalReceiver(CooldownSyncPayload.TYPE, (payload, context) -> { + context.client().execute(() -> { + overlayActive = payload.active(); + cooldownEndEpochMs = payload.cooldownEndEpochMs(); + }); + }); + + HudRenderCallback.EVENT.register((guiGraphics, tickDelta) -> { + if (!overlayActive) { + return; + } + Minecraft client = Minecraft.getInstance(); + if (client.player == null) { + return; + } + long remainingMs = Math.max(0L, cooldownEndEpochMs - System.currentTimeMillis()); + renderOverlay(guiGraphics, client, remainingMs); + }); + } + + private static void renderOverlay(GuiGraphics graphics, Minecraft client, long remainingMs) { + int w = client.getWindow().getGuiScaledWidth(); + int h = client.getWindow().getGuiScaledHeight(); + graphics.fill(0, 0, w, h, 0xFF000000); + + int totalSeconds = (int) (remainingMs / 1000L); + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + String time = String.format("%02d:%02d", minutes, seconds); + Component line = Component.translatable("respawn_backoff.hud.countdown", time); + + int textWidth = client.font.width(line); + graphics.drawString( + client.font, + line, + (w - textWidth) / 2, + h / 2, + 0xFFFFFF, + false + ); + } + + public static void clearForDisconnect() { + overlayActive = false; + cooldownEndEpochMs = 0L; + } +} diff --git a/src/main/java/net/respawnbackoff/CooldownSyncPayload.java b/src/main/java/net/respawnbackoff/CooldownSyncPayload.java new file mode 100644 index 0000000..37edc78 --- /dev/null +++ b/src/main/java/net/respawnbackoff/CooldownSyncPayload.java @@ -0,0 +1,25 @@ +package net.respawnbackoff; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record CooldownSyncPayload(boolean active, long cooldownEndEpochMs) implements CustomPacketPayload { + public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>( + ResourceLocation.fromNamespaceAndPath(RespawnBackoffMod.MOD_ID, "cooldown_sync") + ); + + public static final StreamCodec STREAM_CODEC = StreamCodec.of( + (RegistryFriendlyByteBuf buf, CooldownSyncPayload payload) -> { + buf.writeBoolean(payload.active()); + buf.writeLong(payload.cooldownEndEpochMs()); + }, + buf -> new CooldownSyncPayload(buf.readBoolean(), buf.readLong()) + ); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/net/respawnbackoff/RespawnBackoffCommands.java b/src/main/java/net/respawnbackoff/RespawnBackoffCommands.java new file mode 100644 index 0000000..922f922 --- /dev/null +++ b/src/main/java/net/respawnbackoff/RespawnBackoffCommands.java @@ -0,0 +1,110 @@ +package net.respawnbackoff; + +import java.util.Collection; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.exceptions.CommandSyntaxException; + +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.commands.CommandBuildContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +public final class RespawnBackoffCommands { + private RespawnBackoffCommands() { + } + + public static void register() { + CommandRegistrationCallback.EVENT.register(RespawnBackoffCommands::registerLiteral); + } + + private static void registerLiteral( + CommandDispatcher dispatcher, + CommandBuildContext registryAccess, + Commands.CommandSelection environment + ) { + dispatcher.register( + Commands.literal("respawnbackoff") + .requires(source -> source.hasPermission(2)) + .then( + Commands.literal("reset") + .executes(ctx -> resetBackoff(ctx.getSource().getPlayerOrException(), ctx.getSource())) + .then( + Commands.argument("targets", EntityArgument.players()) + .executes(ctx -> resetBackoff(EntityArgument.getPlayers(ctx, "targets"), ctx.getSource())) + ) + ) + .then( + Commands.literal("skip") + .executes(ctx -> skipWait(ctx.getSource().getPlayerOrException(), ctx.getSource())) + .then( + Commands.argument("targets", EntityArgument.players()) + .executes(ctx -> skipWait(EntityArgument.getPlayers(ctx, "targets"), ctx.getSource())) + ) + ) + ); + } + + private static int resetBackoff(ServerPlayer target, CommandSourceStack source) throws CommandSyntaxException { + RespawnBackoffMod.resetBackoffChain(target); + source.sendSuccess( + () -> Component.translatable("respawn_backoff.command.reset.single", target.getDisplayName()), + true + ); + return 1; + } + + private static int resetBackoff(Collection targets, CommandSourceStack source) { + int n = 0; + for (ServerPlayer target : targets) { + RespawnBackoffMod.resetBackoffChain(target); + n++; + } + if (n == 1) { + source.sendSuccess( + () -> Component.translatable("respawn_backoff.command.reset.single", targets.iterator().next().getDisplayName()), + true + ); + } else { + int count = n; + source.sendSuccess( + () -> Component.translatable("respawn_backoff.command.reset.multiple", count), + true + ); + } + return n; + } + + private static int skipWait(ServerPlayer target, CommandSourceStack source) throws CommandSyntaxException { + RespawnBackoffMod.skipCooldown(target); + source.sendSuccess( + () -> Component.translatable("respawn_backoff.command.skip.single", target.getDisplayName()), + true + ); + return 1; + } + + private static int skipWait(Collection targets, CommandSourceStack source) { + int n = 0; + for (ServerPlayer target : targets) { + RespawnBackoffMod.skipCooldown(target); + n++; + } + if (n == 1) { + source.sendSuccess( + () -> Component.translatable("respawn_backoff.command.skip.single", targets.iterator().next().getDisplayName()), + true + ); + } else { + int count = n; + source.sendSuccess( + () -> Component.translatable("respawn_backoff.command.skip.multiple", count), + true + ); + } + return n; + } +} diff --git a/src/main/java/net/respawnbackoff/RespawnBackoffData.java b/src/main/java/net/respawnbackoff/RespawnBackoffData.java new file mode 100644 index 0000000..416dcb8 --- /dev/null +++ b/src/main/java/net/respawnbackoff/RespawnBackoffData.java @@ -0,0 +1,24 @@ +package net.respawnbackoff; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +public record RespawnBackoffData( + int exponent, + long lastEpochDay, + long pendingDurationMs, + long cooldownEndEpochMs +) { + public static final RespawnBackoffData DEFAULT = new RespawnBackoffData(0, 0L, 0L, 0L); + + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("exponent").forGetter(RespawnBackoffData::exponent), + Codec.LONG.fieldOf("last_epoch_day").forGetter(RespawnBackoffData::lastEpochDay), + Codec.LONG.fieldOf("pending_duration_ms").forGetter(RespawnBackoffData::pendingDurationMs), + Codec.LONG.fieldOf("cooldown_end_ms").forGetter(RespawnBackoffData::cooldownEndEpochMs) + ).apply(instance, RespawnBackoffData::new)); + + public boolean hasActiveCooldown(long nowMs) { + return cooldownEndEpochMs > nowMs; + } +} diff --git a/src/main/java/net/respawnbackoff/RespawnBackoffMod.java b/src/main/java/net/respawnbackoff/RespawnBackoffMod.java new file mode 100644 index 0000000..3e08f26 --- /dev/null +++ b/src/main/java/net/respawnbackoff/RespawnBackoffMod.java @@ -0,0 +1,166 @@ +package net.respawnbackoff; + +import java.time.LocalDate; +import java.time.ZoneOffset; + +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.attachment.v1.AttachmentRegistry; +import net.fabricmc.fabric.api.attachment.v1.AttachmentType; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.GameType; + +public class RespawnBackoffMod implements ModInitializer { + public static final String MOD_ID = "respawn_backoff"; + + public static final AttachmentType RESPAWN_BACKOFF = AttachmentRegistry.create( + ResourceLocation.fromNamespaceAndPath(MOD_ID, "state"), + builder -> builder + .persistent(RespawnBackoffData.CODEC) + .copyOnDeath() + .initializer(() -> RespawnBackoffData.DEFAULT) + ); + + @Override + public void onInitialize() { + PayloadTypeRegistry.playS2C().register(CooldownSyncPayload.TYPE, CooldownSyncPayload.STREAM_CODEC); + RespawnBackoffCommands.register(); + + ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> { + if (!(entity instanceof ServerPlayer player)) { + return; + } + onPlayerDeath(player); + }); + + ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer, newPlayer, alive) -> { + if (!alive) { + onAfterDeathRespawn(newPlayer); + } + }); + + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> { + ServerPlayer player = handler.player; + long now = System.currentTimeMillis(); + RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT); + if (data.hasActiveCooldown(now)) { + if (!player.isSpectator()) { + player.setGameMode(GameType.SPECTATOR); + } + RespawnBackoffNetworking.sendCooldown(player, true, data.cooldownEndEpochMs()); + } else { + RespawnBackoffNetworking.sendCooldown(player, false, 0L); + } + }); + + ServerTickEvents.END_SERVER_TICK.register(server -> { + long now = System.currentTimeMillis(); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + tickPlayer(player, now); + } + }); + } + + private static long currentUtcEpochDay() { + return LocalDate.now(ZoneOffset.UTC).toEpochDay(); + } + + static void onPlayerDeath(ServerPlayer player) { + long today = currentUtcEpochDay(); + RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT); + + int exponent = data.exponent(); + long lastDay = data.lastEpochDay(); + if (lastDay != today) { + exponent = 0; + } + + int cappedExp = Math.min(exponent, 6); + long waitMinutes = Math.min(1L << cappedExp, 64L); + long pendingMs = waitMinutes * 60_000L; + int nextExponent = Math.min(exponent + 1, 6); + + RespawnBackoffData next = new RespawnBackoffData(nextExponent, today, pendingMs, 0L); + player.setAttached(RESPAWN_BACKOFF, next); + } + + private static void onAfterDeathRespawn(ServerPlayer player) { + RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT); + long pending = data.pendingDurationMs(); + if (pending <= 0L) { + return; + } + + long end = System.currentTimeMillis() + pending; + RespawnBackoffData next = new RespawnBackoffData( + data.exponent(), + data.lastEpochDay(), + 0L, + end + ); + player.setAttached(RESPAWN_BACKOFF, next); + player.setGameMode(GameType.SPECTATOR); + RespawnBackoffNetworking.sendCooldown(player, true, end); + } + + private static void tickPlayer(ServerPlayer player, long nowMs) { + RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT); + if (!data.hasActiveCooldown(nowMs)) { + return; + } + + if (nowMs < data.cooldownEndEpochMs()) { + return; + } + + clearCooldownAndRestore(player, data); + } + + /** + * Clears active respawn wait and pending pre-respawn duration; restores survival if needed. + * Does not change the death-count chain (exponent / last day). + */ + public static void skipCooldown(ServerPlayer player) { + RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT); + clearCooldownAndRestore(player, data); + } + + private static void clearCooldownAndRestore(ServerPlayer player, RespawnBackoffData data) { + RespawnBackoffData cleared = new RespawnBackoffData( + data.exponent(), + data.lastEpochDay(), + 0L, + 0L + ); + player.setAttached(RESPAWN_BACKOFF, cleared); + if (player.isSpectator()) { + player.setGameMode(GameType.SURVIVAL); + } + RespawnBackoffNetworking.sendCooldown(player, false, 0L); + } + + /** + * Next death uses the minimum wait (1 minute): exponent 0 and today's UTC day recorded. + * Does not end an active on-screen countdown; use {@link #skipCooldown} for that. + */ + public static void resetBackoffChain(ServerPlayer player) { + long today = currentUtcEpochDay(); + RespawnBackoffData data = player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT); + RespawnBackoffData next = new RespawnBackoffData( + 0, + today, + 0L, + data.cooldownEndEpochMs() + ); + player.setAttached(RESPAWN_BACKOFF, next); + } + + public static boolean isPenaltyActive(ServerPlayer player, long nowMs) { + return player.getAttachedOrElse(RESPAWN_BACKOFF, RespawnBackoffData.DEFAULT).hasActiveCooldown(nowMs); + } +} diff --git a/src/main/java/net/respawnbackoff/RespawnBackoffNetworking.java b/src/main/java/net/respawnbackoff/RespawnBackoffNetworking.java new file mode 100644 index 0000000..d7a8adc --- /dev/null +++ b/src/main/java/net/respawnbackoff/RespawnBackoffNetworking.java @@ -0,0 +1,13 @@ +package net.respawnbackoff; + +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.level.ServerPlayer; + +public final class RespawnBackoffNetworking { + private RespawnBackoffNetworking() { + } + + public static void sendCooldown(ServerPlayer player, boolean active, long cooldownEndEpochMs) { + ServerPlayNetworking.send(player, new CooldownSyncPayload(active, cooldownEndEpochMs)); + } +} diff --git a/src/main/java/net/respawnbackoff/mixin/ServerPlayerGameModeMixin.java b/src/main/java/net/respawnbackoff/mixin/ServerPlayerGameModeMixin.java new file mode 100644 index 0000000..6821b9e --- /dev/null +++ b/src/main/java/net/respawnbackoff/mixin/ServerPlayerGameModeMixin.java @@ -0,0 +1,25 @@ +package net.respawnbackoff.mixin; + +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; + +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.GameType; +import net.respawnbackoff.RespawnBackoffMod; + +@Mixin(ServerPlayer.class) +public class ServerPlayerGameModeMixin { + @Inject(method = "setGameMode", at = @At("HEAD"), cancellable = true) + private void respawn_backoff$enforceSpectatorDuringPenalty(GameType gameType, CallbackInfoReturnable cir) { + if (gameType == GameType.SPECTATOR) { + return; + } + ServerPlayer self = (ServerPlayer) (Object) this; + long now = System.currentTimeMillis(); + if (RespawnBackoffMod.isPenaltyActive(self, now)) { + cir.setReturnValue(false); + } + } +} diff --git a/src/main/resources/assets/respawn_backoff/lang/en_us.json b/src/main/resources/assets/respawn_backoff/lang/en_us.json new file mode 100644 index 0000000..75cb865 --- /dev/null +++ b/src/main/resources/assets/respawn_backoff/lang/en_us.json @@ -0,0 +1,7 @@ +{ + "respawn_backoff.hud.countdown": "Respawn in %s", + "respawn_backoff.command.reset.single": "Reset respawn backoff for %s to the minimum (next death: 1 minute).", + "respawn_backoff.command.reset.multiple": "Reset respawn backoff to the minimum for %s players.", + "respawn_backoff.command.skip.single": "Skipped respawn wait for %s.", + "respawn_backoff.command.skip.multiple": "Skipped respawn wait for %s players." +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..516edf7 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "respawn_backoff", + "version": "${version}", + "name": "Respawn Backoff", + "description": "Doubling respawn delay with spectator blackout until the timer ends.", + "authors": [], + "license": "MIT", + "environment": "*", + "entrypoints": { + "main": [ + "net.respawnbackoff.RespawnBackoffMod" + ], + "client": [ + "net.respawnbackoff.client.RespawnBackoffClient" + ] + }, + "mixins": [ + "respawn_backoff.mixins.json" + ], + "depends": { + "fabricloader": ">=0.18.5", + "minecraft": "~1.21.1", + "java": ">=21", + "fabric-api": "*" + } +} diff --git a/src/main/resources/respawn_backoff.mixins.json b/src/main/resources/respawn_backoff.mixins.json new file mode 100644 index 0000000..7959d72 --- /dev/null +++ b/src/main/resources/respawn_backoff.mixins.json @@ -0,0 +1,11 @@ +{ + "required": true, + "package": "net.respawnbackoff.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "ServerPlayerGameModeMixin" + ], + "injectors": { + "defaultRequire": 1 + } +}