From 8aa1814b718c26150d39c89d35ba50b9bf7d8aa9 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Fri, 7 Aug 2026 21:44:48 +0800 Subject: [PATCH] feat: add play hint for features --- AGENTS.md | 4 + .../io/ib67/sfcraft/SFCraftInitializer.java | 2 + .../io/ib67/sfcraft/callback/SFCallbacks.java | 10 + .../io/ib67/sfcraft/config/GameConfig.java | 8 + .../gameplay/NetherPortalBlockMixin.java | 8 +- .../mixin/gameplay/TotemProtectionMixin.java | 24 +- .../module/game/lunchbox/LunchBoxItem.java | 5 + .../module/game/mount/MountModule.java | 13 + .../module/game/token/PearlTokenItem.java | 22 +- .../game/token/ReversePearlTokenItem.java | 17 +- .../module/hint/FeatureHintModule.java | 246 ++++++++++++++++++ .../io/ib67/sfcraft/module/hint/Hint.java | 48 ++++ .../resources/assets/sfcraft/lang/en_us.json | 11 +- .../resources/assets/sfcraft/lang/zh_cn.json | 11 +- 14 files changed, 408 insertions(+), 21 deletions(-) create mode 100644 src/main/java/io/ib67/sfcraft/module/hint/FeatureHintModule.java create mode 100644 src/main/java/io/ib67/sfcraft/module/hint/Hint.java diff --git a/AGENTS.md b/AGENTS.md index f0d366e..b97d6a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,10 @@ javap -cp ~/.gradle/caches/fabric-loom/minecraftMaven/net/minecraft/minecraft-me 另外,如果你注册了新的物品,确保通过依赖注入把他注册到 ItemGroupService 中。 +如何区分 gameplay 和一般特性: +1. 通常来说,和游戏中的内容(物品,方块,实体)直接挂钩的是 gameplay. 而类似聊天,权限以及主要是辅助工具性质的不属于 gameplay +2. gameplay 通常直接影响游戏逻辑,如果修改并不能让玩家体验到新的世界交互形式,那么不属于 gameplay + 6. 编译验证,必要时 runServer 冒烟。 ## 编码约定 diff --git a/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java b/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java index 11ebeeb..5d53d1a 100644 --- a/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java +++ b/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java @@ -21,6 +21,7 @@ import io.ib67.sfcraft.module.game.cauldron.CauldronModule; import io.ib67.sfcraft.module.game.commander.CommanderModule; import io.ib67.sfcraft.module.game.crystal.GravityCrystalModule; import io.ib67.sfcraft.module.game.lunchbox.LunchBoxModule; +import io.ib67.sfcraft.module.hint.FeatureHintModule; import io.ib67.sfcraft.module.game.mapart.MapArtModule; import io.ib67.sfcraft.module.game.mount.MountModule; import io.ib67.sfcraft.module.game.portal.ElytraPortalModule; @@ -107,6 +108,7 @@ public class SFCraftInitializer extends GuiceModInitializer { registerFeature(TotemModule.class); registerFeature(ElytraPortalModule.class); registerFeature(MapArtModule.class); + registerFeature(FeatureHintModule.class); // ItemGroupModule 必须最后注册:它冻结前面各模块被动贡献到 ItemGroupService 的物品,构建创意物品栏 registerFeature(ItemGroupModule.class); } diff --git a/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java b/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java index e8930a8..7e97802 100644 --- a/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java +++ b/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java @@ -41,6 +41,16 @@ public interface SFCallbacks { Event ANVIL_CREATE_RESULT = EventFactory.createArrayBacked(AnvilCreateResultCallback.class, l -> (m, p) -> forEach(l, i -> i.onAnvilCreateResult(m, p))); + /** 玩家级低频调度(默认 10s 一次),由 FeatureHintModule 在服务端 tick 里发放;任何模块可订阅,复用同一节拍。 */ + Event PLAYER_SLOW_TICK = EventFactory.createArrayBacked(PlayerSlowTickCallback.class, + l -> (p, i) -> forEach(l, it -> it.onPlayerSlowTick(p, i))); + + interface PlayerSlowTickCallback { + int INTERVAL_TICKS = 200; // 10 s;写死,不做 gamerule/config,避免每个监听自己做整数除法 + + void onPlayerSlowTick(ServerPlayer player, int intervalTicks); + } + interface AnvilCreateResultCallback { void onAnvilCreateResult(AnvilMenu menu, Player player); } diff --git a/src/main/java/io/ib67/sfcraft/config/GameConfig.java b/src/main/java/io/ib67/sfcraft/config/GameConfig.java index 971f23b..4748498 100644 --- a/src/main/java/io/ib67/sfcraft/config/GameConfig.java +++ b/src/main/java/io/ib67/sfcraft/config/GameConfig.java @@ -18,6 +18,7 @@ public class GameConfig { public StandingFirmTotem standingFirmTotem = new StandingFirmTotem(); public ElytraPortal elytraPortal = new ElytraPortal(); public MapArt mapArt = new MapArt(); + public FeatureHint featureHint = new FeatureHint(); public static class ElytraPortal { // 鞘翅滑翔冲进下界传送门时跳过等待(立即传送)的最低速度,单位米/秒;<= 0 关闭本特性 @@ -138,4 +139,11 @@ public class GameConfig { public float dungeonObsidianBombChance = 0.15F; public float creeperBombChance = 0.1F; } + + public static class FeatureHint { + // 服主级总开关;关掉立即静默所有提示 + public boolean enabled = true; + // 同一玩家连续收到两条提示之间的最小间隔(秒),防止被多条特性连环打扰 + public int globalCooldownSeconds = 120; + } } diff --git a/src/main/java/io/ib67/sfcraft/mixin/gameplay/NetherPortalBlockMixin.java b/src/main/java/io/ib67/sfcraft/mixin/gameplay/NetherPortalBlockMixin.java index 19b0029..91435f6 100644 --- a/src/main/java/io/ib67/sfcraft/mixin/gameplay/NetherPortalBlockMixin.java +++ b/src/main/java/io/ib67/sfcraft/mixin/gameplay/NetherPortalBlockMixin.java @@ -1,8 +1,10 @@ package io.ib67.sfcraft.mixin.gameplay; import io.ib67.sfcraft.SFCraft; +import io.ib67.sfcraft.module.hint.FeatureHintModule; import io.ib67.sfcraft.module.game.portal.ElytraPortalModule; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.level.block.NetherPortalBlock; import org.spongepowered.asm.mixin.Mixin; @@ -13,12 +15,16 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; /** * 鞘翅冲出下界传送门时跳过等待:本方法每 tick 被 {@code PortalProcessor} 调用来决定 * 「还要在门里待多久」,命中条件时覆写为 1 tick(与原版创造模式一致),下一 tick 即跨维度; - * 否则不动 cir,等待时间完全由原版游戏规则决定。判定逻辑见 {@link ElytraPortalModule#shouldTeleportNow}。 + * 否则不动 cir。判定逻辑见 {@link ElytraPortalModule#shouldTeleportNow}。 */ @Mixin(NetherPortalBlock.class) public abstract class NetherPortalBlockMixin { @Inject(method = "getPortalTransitionTime", at = @At("HEAD"), cancellable = true) private void sfcraft$elytraRushPortal(ServerLevel level, Entity entity, CallbackInfoReturnable cir) { + if (entity instanceof ServerPlayer player) { + // 每 tick 在门内回调一次:滑翔免教 / 站着等门提示 + SFCraft.getInjector().getInstance(FeatureHintModule.class).mixin$onPortalTick(player); + } var module = SFCraft.getInjector().getInstance(ElytraPortalModule.class); if (module.shouldTeleportNow(entity)) { cir.setReturnValue(module.getInstantTransitionTicks()); diff --git a/src/main/java/io/ib67/sfcraft/mixin/gameplay/TotemProtectionMixin.java b/src/main/java/io/ib67/sfcraft/mixin/gameplay/TotemProtectionMixin.java index 1b9dcaa..f9e05bb 100644 --- a/src/main/java/io/ib67/sfcraft/mixin/gameplay/TotemProtectionMixin.java +++ b/src/main/java/io/ib67/sfcraft/mixin/gameplay/TotemProtectionMixin.java @@ -1,11 +1,14 @@ package io.ib67.sfcraft.mixin.gameplay; import io.ib67.sfcraft.SFCraft; +import io.ib67.sfcraft.module.hint.FeatureHintModule; +import io.ib67.sfcraft.module.hint.Hint; import io.ib67.sfcraft.module.game.totem.TotemModule; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.entity.LivingEntity; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @@ -18,12 +21,29 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; */ @Mixin(LivingEntity.class) public abstract class TotemProtectionMixin { + @Unique + private TotemModule.Result sfcraft$lastTotemResult; + @Inject(method = "checkTotemDeathProtection", at = @At("HEAD"), cancellable = true) private void sfcraft$standingFirm(DamageSource source, CallbackInfoReturnable cir) { if (!((Object) this instanceof ServerPlayer player)) return; - // 只有触发成功才拦截返回 true;其余情形不动 cir,让原版逻辑继续(原版图腾等照常兜底) - if (SFCraft.getInjector().getInstance(TotemModule.class).tryProtect(player, source) == TotemModule.Result.PROTECTED) { + var result = SFCraft.getInjector().getInstance(TotemModule.class).tryProtect(player, source); + sfcraft$lastTotemResult = result; + if (result == TotemModule.Result.PROTECTED) { cir.setReturnValue(true); + // 玩家已有代偿图腾并且真触发过——已经领到教训,不需要 hint + SFCraft.getInjector().getInstance(FeatureHintModule.class).markUsed(player, Hint.STANDING_FIRM_TOTEM); } + // DECLINED(拿了但 CD/经验不够):不动,让玩家按原版规律死掉,不发嘲讽 + } + + @Inject(method = "checkTotemDeathProtection", at = @At("RETURN")) + private void sfcraft$vanillaTotemSaved(DamageSource source, CallbackInfoReturnable cir) { + if (!((Object) this instanceof ServerPlayer player)) return; + // 原版图腾已当场救活(返回值 true)且玩家没拿代偿图腾——直接提示,无需延迟等待 + if (sfcraft$lastTotemResult != TotemModule.Result.NOT_APPLICABLE) return; + if (!Boolean.TRUE.equals(cir.getReturnValue())) return; + FeatureHintModule featureHintModule = SFCraft.getInjector().getInstance(FeatureHintModule.class); + featureHintModule.tryEmitHint(player, Hint.STANDING_FIRM_TOTEM); } } diff --git a/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxItem.java b/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxItem.java index 1de9528..60ed261 100644 --- a/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxItem.java +++ b/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxItem.java @@ -1,6 +1,9 @@ package io.ib67.sfcraft.module.game.lunchbox; import eu.pb4.polymer.core.api.item.PolymerItem; +import io.ib67.sfcraft.SFCraft; +import io.ib67.sfcraft.module.hint.FeatureHintModule; +import io.ib67.sfcraft.module.hint.Hint; import net.fabricmc.fabric.api.networking.v1.context.PacketContext; import net.minecraft.core.HolderLookup; import net.minecraft.core.NonNullList; @@ -66,6 +69,8 @@ public class LunchBoxItem extends Item implements PolymerItem { var consumable = stack.get(DataComponents.CONSUMABLE); if (consumable == null) return InteractionResult.FAIL; // 走原版进食流程(动画+咀嚼音效),结束后进入下方 finishUsingItem + SFCraft.getInjector().getInstance(FeatureHintModule.class) + .markUsed(serverPlayer, Hint.LUNCHBOX_EAT); return consumable.startConsuming(player, stack, hand); } diff --git a/src/main/java/io/ib67/sfcraft/module/game/mount/MountModule.java b/src/main/java/io/ib67/sfcraft/module/game/mount/MountModule.java index aea6aa4..fce864b 100644 --- a/src/main/java/io/ib67/sfcraft/module/game/mount/MountModule.java +++ b/src/main/java/io/ib67/sfcraft/module/game/mount/MountModule.java @@ -1,8 +1,11 @@ package io.ib67.sfcraft.module.game.mount; import com.google.inject.Inject; +import io.ib67.sfcraft.SFCraft; import io.ib67.sfcraft.ServerModule; import io.ib67.sfcraft.config.GameConfigService; +import io.ib67.sfcraft.module.hint.FeatureHintModule; +import io.ib67.sfcraft.module.hint.Hint; import net.fabricmc.fabric.api.event.player.UseEntityCallback; import net.fabricmc.fabric.api.event.player.UseItemCallback; import net.minecraft.core.particles.ParticleTypes; @@ -64,6 +67,11 @@ public class MountModule extends ServerModule { if (mob.getRandom().nextFloat() < mountConfig.tameSuccessChance) { mount.sfcraft$setOwner(player.getUUID()); mob.setPersistenceRequired(); + if (player instanceof ServerPlayer serverPlayer) { + // 玩家已掌握驯服,永久静默驯服提示 + SFCraft.getInjector().getInstance(FeatureHintModule.class) + .markUsed(serverPlayer, Hint.MOUNT_TAME_NEARBY); + } celebrate(mob); } else { tameFailed(mob); @@ -118,6 +126,11 @@ public class MountModule extends ServerModule { } player.getCooldowns().addCooldown(held, dash.cooldownTicks); if (dash.durabilityCost > 0) held.hurtAndBreak(dash.durabilityCost, player, hand); + if (player instanceof ServerPlayer serverPlayer) { + // 玩家已掌握劫掠兽冲刺,永久静默冲刺提示 + SFCraft.getInjector().getInstance(FeatureHintModule.class) + .markUsed(serverPlayer, Hint.RAVAGER_DASH); + } return InteractionResult.SUCCESS_SERVER; } diff --git a/src/main/java/io/ib67/sfcraft/module/game/token/PearlTokenItem.java b/src/main/java/io/ib67/sfcraft/module/game/token/PearlTokenItem.java index e27df4d..0391e99 100644 --- a/src/main/java/io/ib67/sfcraft/module/game/token/PearlTokenItem.java +++ b/src/main/java/io/ib67/sfcraft/module/game/token/PearlTokenItem.java @@ -1,6 +1,9 @@ package io.ib67.sfcraft.module.game.token; import eu.pb4.polymer.core.api.item.PolymerItem; +import io.ib67.sfcraft.SFCraft; +import io.ib67.sfcraft.module.hint.FeatureHintModule; +import io.ib67.sfcraft.module.hint.Hint; import io.ib67.sfcraft.module.game.item.ItemLores; import net.fabricmc.fabric.api.networking.v1.context.PacketContext; import net.minecraft.core.component.DataComponents; @@ -56,18 +59,25 @@ public class PearlTokenItem extends Item implements PolymerItem { return InteractionResult.FAIL; } - var targetLevel = (ServerLevel) serverPlayer.level(); + var targetLevel = serverPlayer.level(); + tokenTeleportWithSound(serverPlayer, owner, targetLevel); + owner.sendSystemMessage(Component.translatable("message.sfcraft.token.summoned_owner", + serverPlayer.getGameProfile().name())); + serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.summon_success", getOwnerName(stack))); + stack.consume(1, serverPlayer); + // 告诉被召唤的玩家:反向信物能把这个方向反过来 + FeatureHintModule featureHintModule = SFCraft.getInjector().getInstance(FeatureHintModule.class); + featureHintModule.tryEmitHint(owner, Hint.REVERSE_TOKEN_AVAILABLE); + return InteractionResult.SUCCESS_SERVER; + } + + static void tokenTeleportWithSound(ServerPlayer serverPlayer, ServerPlayer owner, ServerLevel targetLevel) { owner.teleportTo(targetLevel, serverPlayer.getX(), serverPlayer.getY(), serverPlayer.getZ(), Set.of(), owner.getYRot(), owner.getXRot(), false); targetLevel.sendParticles(ParticleTypes.PORTAL, serverPlayer.getX(), serverPlayer.getY() + 1.0, serverPlayer.getZ(), 32, 0.5, 1.0, 0.5, 0.2); targetLevel.playSound(null, serverPlayer.getX(), serverPlayer.getY(), serverPlayer.getZ(), SoundEvents.ENDERMAN_TELEPORT, SoundSource.PLAYERS, 1.0F, 1.0F); - owner.sendSystemMessage(Component.translatable("message.sfcraft.token.summoned_owner", - serverPlayer.getGameProfile().name())); - serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.summon_success", getOwnerName(stack))); - stack.consume(1, serverPlayer); - return InteractionResult.SUCCESS_SERVER; } private static void bindOne(ServerPlayer player, InteractionHand hand, ItemStack stack) { diff --git a/src/main/java/io/ib67/sfcraft/module/game/token/ReversePearlTokenItem.java b/src/main/java/io/ib67/sfcraft/module/game/token/ReversePearlTokenItem.java index 160feb9..fbc2dea 100644 --- a/src/main/java/io/ib67/sfcraft/module/game/token/ReversePearlTokenItem.java +++ b/src/main/java/io/ib67/sfcraft/module/game/token/ReversePearlTokenItem.java @@ -1,16 +1,16 @@ package io.ib67.sfcraft.module.game.token; import eu.pb4.polymer.core.api.item.PolymerItem; +import io.ib67.sfcraft.SFCraft; import io.ib67.sfcraft.config.GameConfigService; +import io.ib67.sfcraft.module.hint.FeatureHintModule; +import io.ib67.sfcraft.module.hint.Hint; import io.ib67.sfcraft.module.game.item.ItemLores; import net.fabricmc.fabric.api.networking.v1.context.PacketContext; import net.minecraft.core.component.DataComponents; -import net.minecraft.core.particles.ParticleTypes; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.sounds.SoundEvents; -import net.minecraft.sounds.SoundSource; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; @@ -22,7 +22,6 @@ import net.minecraft.world.item.component.ItemLore; import net.minecraft.world.level.Level; import java.util.List; -import java.util.Set; import java.util.UUID; public class ReversePearlTokenItem extends Item implements PolymerItem { @@ -85,14 +84,12 @@ public class ReversePearlTokenItem extends Item implements PolymerItem { } var targetLevel = (ServerLevel) owner.level(); - serverPlayer.teleportTo(targetLevel, owner.getX(), owner.getY(), owner.getZ(), - Set.of(), serverPlayer.getYRot(), serverPlayer.getXRot(), false); - targetLevel.sendParticles(ParticleTypes.PORTAL, - owner.getX(), owner.getY() + 1.0, owner.getZ(), 32, 0.5, 1.0, 0.5, 0.2); - targetLevel.playSound(null, owner.getX(), owner.getY(), owner.getZ(), - SoundEvents.ENDERMAN_TELEPORT, SoundSource.PLAYERS, 1.0F, 1.0F); + PearlTokenItem.tokenTeleportWithSound(owner, serverPlayer, targetLevel); owner.sendSystemMessage(Component.translatable("message.sfcraft.token.arrived", serverPlayer.getGameProfile().name())); + // 玩家自己已使用反向信物,反向信物 hint 永久静默 + SFCraft.getInjector().getInstance(FeatureHintModule.class) + .markUsed(serverPlayer, Hint.REVERSE_TOKEN_AVAILABLE); // 记录本次使用时间戳(冷却),再扣除耐久(使用次数) CustomData.update(DataComponents.CUSTOM_DATA, stack, t -> t.putLong(LAST_USE_KEY, now)); diff --git a/src/main/java/io/ib67/sfcraft/module/hint/FeatureHintModule.java b/src/main/java/io/ib67/sfcraft/module/hint/FeatureHintModule.java new file mode 100644 index 0000000..1d7ee24 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/module/hint/FeatureHintModule.java @@ -0,0 +1,246 @@ +package io.ib67.sfcraft.module.hint; + +import com.google.inject.Inject; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import io.ib67.sfcraft.ServerModule; +import io.ib67.sfcraft.callback.SFCallbacks; +import io.ib67.sfcraft.config.GameConfigService; +import io.ib67.sfcraft.module.game.lunchbox.LunchBoxItem; +import io.ib67.sfcraft.module.game.mount.MountAccess; +import io.ib67.sfcraft.module.game.mount.MountLogic; +import io.ib67.sfcraft.util.Helper; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.player.UseItemCallback; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandBuildContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.monster.Ravager; +import net.minecraft.world.entity.monster.hoglin.Hoglin; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * 特性提示模块:玩家「正在用低效方式做事」或「尚未发现相关特性」时,以一次性、低频、 + * 可点击隐藏的方式发提示。触发点全部挂在事件/mixin 回调上,不做逐 tick 轮询。 + */ +public class FeatureHintModule extends ServerModule { + private static final String TAG_PREFIX_SEEN = "sfhint_seen."; + private static final String TAG_PREFIX_USED = "sfhint_used."; + private static final String TAG_OPTED_OUT = "sfhint_opted_out"; + + private static final int JOIN_SILENCE_TICKS = 200; + private static final int ELYTRA_STALL_TICKS = 2; + private static final int MOUNT_HINT_RANGE = 32; + private static final int LUNCHBOX_MIN_LEVEL = 20; + private static final float LUNCHBOX_HINT_CHANCE = 0.3f; + + @Inject + private GameConfigService config; + + /** 全局节流:同一玩家两条提示之间的最小时间戳间隔(毫秒)。 */ + private final Map lastAnyAt = new HashMap<>(); + /** 已提示过的可驯服坐骑,全局去重(同一只怪只推荐一次)。 */ + private final Set hintedMounts = new HashSet<>(); + private int slowTickCounter; + + @Override + public void onInitialize() { + UseItemCallback.EVENT.register(this::onUseItem); + SFCallbacks.PLAYER_SLOW_TICK.register(this::onSlowTick); + ServerTickEvents.END_SERVER_TICK.register(this::onTick); + CommandRegistrationCallback.EVENT.register(this::registerCommands); + } + + // ---------- 公共 API(mixin / 物品回调调用) ---------- + + /** 玩家已掌握某特性,该提示永久静默。 */ + public void markUsed(ServerPlayer player, Hint hint) { + player.addTag(TAG_PREFIX_USED + hint.id()); + } + + /** 玩家处于传送门内的每 tick 回调(NetherPortalBlockMixin):滑翔免教,站着等门则提示。 */ + public void mixin$onPortalTick(ServerPlayer player) { + if (player.isFallFlying()) { + markUsed(player, Hint.ELYTRA_PORTAL_RUSH); + } else if (player.getItemBySlot(EquipmentSlot.CHEST).is(Items.ELYTRA) + && player.portalProcess != null + && player.portalProcess.getPortalTime() >= ELYTRA_STALL_TICKS) { + tryEmitHint(player, Hint.ELYTRA_PORTAL_RUSH); + } + } + + // ---------- 事件回调 ---------- + + private InteractionResult onUseItem(Player player, Level level, InteractionHand hand) { + if (level.isClientSide() || hand != InteractionHand.MAIN_HAND) return InteractionResult.PASS; + if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.PASS; + if (!config.get().featureHint.enabled) return InteractionResult.PASS; + var stack = player.getItemInHand(hand); + // 骑自家劫掠兽用胡萝卜钓竿(冲或不冲)= 已掌握冲刺手势,免教 + if (stack.is(Items.CARROT_ON_A_STICK) + && player.getVehicle() instanceof Ravager rav + && rav instanceof MountAccess mount + && serverPlayer.getUUID().equals(mount.sfcraft$getOwner())) { + markUsed(serverPlayer, Hint.RAVAGER_DASH); + return InteractionResult.PASS; + } + maybeHintLunchBox(serverPlayer, stack); + return InteractionResult.PASS; + } + + /** 高等级玩家拿普通食物开吃且背包有 Bundle 时,小概率提示午餐盒。 */ + private void maybeHintLunchBox(ServerPlayer player, ItemStack stack) { + if (player.experienceLevel <= LUNCHBOX_MIN_LEVEL) return; + if (stack.getItem() instanceof LunchBoxItem) return; + var food = stack.get(DataComponents.FOOD); + if (food == null) { + if (!stack.has(DataComponents.CONSUMABLE)) return; + } else if (!player.canEat(food.canAlwaysEat())) { + return; // 吃饱了,进食不会真正开始 + } + if (!player.getInventory().contains(s -> s.is(Items.BUNDLE))) return; + if (player.getRandom().nextFloat() >= LUNCHBOX_HINT_CHANCE) return; + tryEmitHint(player, Hint.LUNCHBOX_EAT); + } + + private void onSlowTick(ServerPlayer player, int intervalTicks) { + if (!config.get().featureHint.enabled) return; + var box = player.getBoundingBox().inflate(MOUNT_HINT_RANGE); + if (hintUntamedNearby(player, Ravager.class, box)) return; + hintUntamedNearby(player, Hoglin.class, box); + } + + private boolean hintUntamedNearby(ServerPlayer player, Class type, AABB box) { + for (T mob : player.level().getEntitiesOfClass(type, box, FeatureHintModule::untamedAndReady)) { + if (hintedMounts.add(mob.getUUID())) { + tryEmitHint(player, Hint.MOUNT_TAME_NEARBY, mob.getDisplayName()); + return true; + } + } + return false; + } + + private static boolean untamedAndReady(Mob mob) { + if (!(mob instanceof MountAccess mount) || mount.sfcraft$getOwner() != null) return false; + return MountLogic.tameBlockReason(mob) == null; + } + + // ---------- 服务端 tick:仅发放 PLAYER_SLOW_TICK(10s)节拍,供各模块低频检测复用 ---------- + + private void onTick(MinecraftServer server) { + if (++slowTickCounter < SFCallbacks.PlayerSlowTickCallback.INTERVAL_TICKS) return; + slowTickCounter = 0; + for (ServerPlayer p : server.getPlayerList().getPlayers()) { + SFCallbacks.PLAYER_SLOW_TICK.invoker().onPlayerSlowTick( + p, SFCallbacks.PlayerSlowTickCallback.INTERVAL_TICKS); + } + } + + // ---------- 提示发送闸 ---------- + + /** 唯一发射点:opt-out / seen / used / 全局节流 / 入服静默,任一不过即不发。 */ + public void tryEmitHint(ServerPlayer player, Hint hint, Object... localizedArgs) { + var cfg = config.get().featureHint; + if (!cfg.enabled) return; + if (player.entityTags().contains(TAG_OPTED_OUT)) return; + if (player.entityTags().contains(TAG_PREFIX_SEEN + hint.id())) return; + if (player.entityTags().contains(TAG_PREFIX_USED + hint.id())) return; + long now = System.currentTimeMillis(); + if (now - lastAnyAt.getOrDefault(player.getUUID(), 0L) < cfg.globalCooldownSeconds * 1000L) return; + if (player.tickCount < JOIN_SILENCE_TICKS) return; + + player.sendSystemMessage(buildHintComponent(hint, localizedArgs)); + player.addTag(TAG_PREFIX_SEEN + hint.id()); + if (hint.autoUsedOnEmit()) markUsed(player, hint); + lastAnyAt.put(player.getUUID(), now); + Helper.playNotifySound(player, SoundEvents.UI_BUTTON_CLICK.value(), SoundSource.PLAYERS, 0.4f, 1.6f); + } + + private Component buildHintComponent(Hint hint, Object... args) { + Component body = Component.translatable(hint.langKey(), args); + Component dismiss = Component.translatable("message.sfcraft.hint.dismiss") + .withStyle(style -> style + .applyFormat(ChatFormatting.GRAY) + .withUnderlined(true) + .withClickEvent(new ClickEvent.RunCommand("/sfcraft hint hide " + hint.id())) + .withHoverEvent(new HoverEvent.ShowText( + Component.translatable("message.sfcraft.hint.dismiss.hover")))); + return Component.empty().append(body).append(dismiss); + } + + // ---------- /sfcraft hint 命令 ---------- + + private void registerCommands(CommandDispatcher dispatcher, + CommandBuildContext registryAccess, + Commands.CommandSelection env) { + var hint = Commands.literal("hint") + .requires(source -> source.isPlayer()) + .then(Commands.literal("hide") + .then(Commands.argument("id", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest( + Arrays.stream(Hint.values()).map(Hint::id).toList(), builder)) + .executes(ctx -> hideHint(ctx.getSource(), StringArgumentType.getString(ctx, "id"))))) + .then(Commands.literal("off").executes(ctx -> setOptOut(ctx.getSource(), true))) + .then(Commands.literal("on").executes(ctx -> setOptOut(ctx.getSource(), false))) + .then(Commands.literal("list").executes(ctx -> listHints(ctx.getSource()))); + dispatcher.register(Commands.literal("sfcraft").then(hint)); + } + + private int hideHint(CommandSourceStack source, String id) { + Hint hint = Hint.byId(id); + if (hint == null) { + source.sendFailure(Component.translatable("message.sfcraft.hint.unknown", id)); + return 0; + } + var player = (ServerPlayer) source.getEntity(); + player.addTag(TAG_PREFIX_SEEN + hint.id()); + source.sendSystemMessage(Component.translatable("message.sfcraft.hint.dismiss.hover")); + return 1; + } + + private int setOptOut(CommandSourceStack source, boolean optOut) { + var player = (ServerPlayer) source.getEntity(); + if (optOut) player.addTag(TAG_OPTED_OUT); + else player.removeTag(TAG_OPTED_OUT); + source.sendSystemMessage(Component.translatable("message.sfcraft.hint.dismiss.hover")); + return 1; + } + + private int listHints(CommandSourceStack source) { + var player = (ServerPlayer) source.getEntity(); + var sb = new StringBuilder("Hints: "); + for (Hint hint : Hint.values()) { + if (player.entityTags().contains(TAG_PREFIX_SEEN + hint.id())) sb.append(hint.id()).append("(seen) "); + else if (player.entityTags().contains(TAG_PREFIX_USED + hint.id())) sb.append(hint.id()).append("(used) "); + } + if (player.entityTags().contains(TAG_OPTED_OUT)) sb.append("[opted_out]"); + source.sendSystemMessage(Component.literal(sb.toString())); + return 1; + } +} diff --git a/src/main/java/io/ib67/sfcraft/module/hint/Hint.java b/src/main/java/io/ib67/sfcraft/module/hint/Hint.java new file mode 100644 index 0000000..b063114 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/module/hint/Hint.java @@ -0,0 +1,48 @@ +package io.ib67.sfcraft.module.hint; + +/** + * 一条提示的静态定义:id 同时作为实体 tag 后缀({@code sfhint_seen.} / {@code sfhint_used.})。 + * 所有提示对同一玩家均为一次性:emit 后打 seen tag,永不重发,故无需冷却字段。 + */ +public enum Hint { + ELYTRA_PORTAL_RUSH("elytra_portal_rush", "message.sfcraft.hint.elytra_portal_rush"), + MOUNT_TAME_NEARBY("mount_tame_nearby", "message.sfcraft.hint.mount_tame_nearby"), + RAVAGER_DASH("ravager_dash", "message.sfcraft.hint.ravager_dash"), + LUNCHBOX_EAT("lunchbox_eat", "message.sfcraft.hint.lunchbox_eat"), + REVERSE_TOKEN_AVAILABLE("reverse_token_available", "message.sfcraft.hint.reverse_token_available", true), + STANDING_FIRM_TOTEM("standing_firm_totem", "message.sfcraft.hint.standing_firm_totem"); + + private final String id; + private final String langKey; + private final boolean autoUsedOnEmit; + + Hint(String id, String langKey) { + this(id, langKey, false); + } + + Hint(String id, String langKey, boolean autoUsedOnEmit) { + this.id = id; + this.langKey = langKey; + this.autoUsedOnEmit = autoUsedOnEmit; + } + + public String id() { + return id; + } + + public String langKey() { + return langKey; + } + + /** true 表示一旦提示过即视作「已使用」,永远不再发(适用瞬时一次性事件)。 */ + public boolean autoUsedOnEmit() { + return autoUsedOnEmit; + } + + public static Hint byId(String id) { + for (Hint hint : values()) { + if (hint.id.equals(id)) return hint; + } + return null; + } +} diff --git a/src/main/resources/assets/sfcraft/lang/en_us.json b/src/main/resources/assets/sfcraft/lang/en_us.json index aa137dd..362a337 100644 --- a/src/main/resources/assets/sfcraft/lang/en_us.json +++ b/src/main/resources/assets/sfcraft/lang/en_us.json @@ -102,5 +102,14 @@ "message.sfcraft.mapart.expand.wall_only": "Map art can only be hung on a vertical wall", "message.sfcraft.mapart.expand.no_space": "Not enough room; this art needs a clear %s x %s wall", "block.sfcraft.map_art": "Map Art", - "item.sfcraft.standing_firm_totem": "Totem of Restitution" + "item.sfcraft.standing_firm_totem": "Totem of Restitution", + "message.sfcraft.hint.elytra_portal_rush": "TIP: Glide in with an elytra — portals let you through instantly", + "message.sfcraft.hint.mount_tame_nearby": "TIP: You might actually be able to tame that %s", + "message.sfcraft.hint.ravager_dash": "TIP: While riding a ravager, clap the carrot-on-a-stick to charge", + "message.sfcraft.hint.lunchbox_eat": "TIP: Keep snacks in a lunch box — easier to manage", + "message.sfcraft.hint.reverse_token_available": "TIP: A Reverse Pearl Token flips the direction of the trip", + "message.sfcraft.hint.standing_firm_totem": "TIP: The Standfast Totem can also negate death — at the cost of XP", + "message.sfcraft.hint.dismiss": " [dismiss]", + "message.sfcraft.hint.dismiss.hover": "Don't show this tip again", + "message.sfcraft.hint.unknown": "No such hint: %s" } diff --git a/src/main/resources/assets/sfcraft/lang/zh_cn.json b/src/main/resources/assets/sfcraft/lang/zh_cn.json index afffba5..329659d 100644 --- a/src/main/resources/assets/sfcraft/lang/zh_cn.json +++ b/src/main/resources/assets/sfcraft/lang/zh_cn.json @@ -102,5 +102,14 @@ "message.sfcraft.mapart.expand.wall_only": "地图画只能挂在竖直墙面上", "message.sfcraft.mapart.expand.no_space": "墙面空间不足,这幅画需要 %s x %s 格空墙", "block.sfcraft.map_art": "地图画", - "item.sfcraft.standing_firm_totem": "代偿图腾" + "item.sfcraft.standing_firm_totem": "代偿图腾", + "message.sfcraft.hint.elytra_portal_rush": "TIP: 使用鞘翅飞行可以直接冲进地狱", + "message.sfcraft.hint.mount_tame_nearby": "TIP: 或许即使是%s也有办法驯服?", + "message.sfcraft.hint.ravager_dash": "TIP: 骑劫掠兽时可以使用胡萝卜钓竿冲撞", + "message.sfcraft.hint.lunchbox_eat": "TIP: 使用午餐盒可以更方便的管理食物", + "message.sfcraft.hint.reverse_token_available": "TIP: 使用反向珍珠信物可以反转传送的方向", + "message.sfcraft.hint.standing_firm_totem": "TIP: 代偿图腾可以以经验为代价反复抵消死亡", + "message.sfcraft.hint.dismiss": " [不再提示]", + "message.sfcraft.hint.dismiss.hover": "不再提示此类消息", + "message.sfcraft.hint.unknown": "没有这个提示:%s" }