feat: add play hint for features
This commit is contained in:
14 files changed
+408
-21
No files matched your search
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user