Initial commit

This commit is contained in:
2026-04-10 01:11:20 +03:00
commit b9a7ccf4be
18 changed files with 712 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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<CooldownSyncPayload> TYPE = new CustomPacketPayload.Type<>(
ResourceLocation.fromNamespaceAndPath(RespawnBackoffMod.MOD_ID, "cooldown_sync")
);
public static final StreamCodec<RegistryFriendlyByteBuf, CooldownSyncPayload> 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<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -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<CommandSourceStack> 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<ServerPlayer> 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<ServerPlayer> 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;
}
}

View File

@@ -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<RespawnBackoffData> 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;
}
}

View File

@@ -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<RespawnBackoffData> 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);
}
}

View File

@@ -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));
}
}

View File

@@ -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<Boolean> cir) {
if (gameType == GameType.SPECTATOR) {
return;
}
ServerPlayer self = (ServerPlayer) (Object) this;
long now = System.currentTimeMillis();
if (RespawnBackoffMod.isPenaltyActive(self, now)) {
cir.setReturnValue(false);
}
}
}

View File

@@ -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."
}

View File

@@ -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": "*"
}
}

View File

@@ -0,0 +1,11 @@
{
"required": true,
"package": "net.respawnbackoff.mixin",
"compatibilityLevel": "JAVA_21",
"mixins": [
"ServerPlayerGameModeMixin"
],
"injectors": {
"defaultRequire": 1
}
}