feat: add play hint for features

This commit is contained in:
iceBear67 committed 2026-08-07 21:44:48 +08:00
1 parent 17c5fe964e
commit 8aa1814b71
14 files changed
+408 -21

No files matched your search

+4
View File
@@ -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 冒烟。
## 编码约定
@@ -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);
}
@@ -41,6 +41,16 @@ public interface SFCallbacks {
Event<AnvilCreateResultCallback> ANVIL_CREATE_RESULT = EventFactory.createArrayBacked(AnvilCreateResultCallback.class,
l -> (m, p) -> forEach(l, i -> i.onAnvilCreateResult(m, p)));
/** 玩家级低频调度(默认 10s 一次),由 FeatureHintModule 在服务端 tick 里发放;任何模块可订阅,复用同一节拍。 */
Event<PlayerSlowTickCallback> 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);
}
@@ -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;
}
}
@@ -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<Integer> 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());
@@ -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<Boolean> 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<Boolean> 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);
}
}
@@ -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);
}
@@ -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;
}
@@ -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) {
@@ -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));
@@ -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<UUID, Long> lastAnyAt = new HashMap<>();
/** 已提示过的可驯服坐骑,全局去重(同一只怪只推荐一次)。 */
private final Set<UUID> 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 <T extends Mob> boolean hintUntamedNearby(ServerPlayer player, Class<T> 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<CommandSourceStack> 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;
}
}
@@ -0,0 +1,48 @@
package io.ib67.sfcraft.module.hint;
/**
* 一条提示的静态定义:id 同时作为实体 tag 后缀({@code sfhint_seen.<id>} / {@code sfhint_used.<id>})。
* 所有提示对同一玩家均为一次性: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;
}
}
@@ -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"
}
@@ -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"
}