@@ -0,0 +1,148 @@
|
||||
package com.disklexar.mmorpg;
|
||||
|
||||
import com.disklexar.mmorpg.bootstrap.Bootstrap;
|
||||
import com.disklexar.mmorpg.bootstrap.ServiceRegistry;
|
||||
import com.disklexar.mmorpg.combat.ClassAbilityController;
|
||||
import com.disklexar.mmorpg.combat.CombatBuffService;
|
||||
import com.disklexar.mmorpg.combat.CombatStateService;
|
||||
import com.disklexar.mmorpg.combat.PoisonService;
|
||||
import com.disklexar.mmorpg.combat.system.MinerBlockBreakSystem;
|
||||
import com.disklexar.mmorpg.combat.system.MmorpgDamageSystem;
|
||||
import com.disklexar.mmorpg.combat.system.MmorpgDeathSystem;
|
||||
import com.disklexar.mmorpg.command.MmorpgCommand;
|
||||
import com.disklexar.mmorpg.economy.EconomyService;
|
||||
import com.disklexar.mmorpg.events.AbilityPacketFilter;
|
||||
import com.disklexar.mmorpg.events.PlayerConnectionHandler;
|
||||
import com.disklexar.mmorpg.interaction.MmorpgCastAbilityInteraction;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.progression.ProgressionService;
|
||||
import com.disklexar.mmorpg.rpg.group.GroupService;
|
||||
import com.disklexar.mmorpg.rpg.job.JobService;
|
||||
import com.disklexar.mmorpg.ui.PlayerInfoPage;
|
||||
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
|
||||
import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent;
|
||||
import com.hypixel.hytale.server.core.io.adapter.PacketAdapters;
|
||||
import com.hypixel.hytale.server.core.io.adapter.PacketFilter;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.server.OpenCustomUIInteraction;
|
||||
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
|
||||
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Main entry point for the Disklexar Hytale MMORPG plugin.
|
||||
*/
|
||||
public final class MmorpgPlugin extends JavaPlugin {
|
||||
|
||||
private static MmorpgPlugin instance;
|
||||
|
||||
private Bootstrap bootstrap;
|
||||
private ClassAbilityController classAbilityController;
|
||||
private PlayerConnectionHandler connectionHandler;
|
||||
private AbilityPacketFilter abilityPacketFilter;
|
||||
private PacketFilter inboundPacketFilter;
|
||||
|
||||
public MmorpgPlugin(@Nonnull JavaPluginInit init) {
|
||||
super(init);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MmorpgPlugin get() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Bootstrap getBootstrap() {
|
||||
return bootstrap;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public ClassAbilityController getClassAbilityController() {
|
||||
return classAbilityController;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setup() {
|
||||
instance = this;
|
||||
classAbilityController = new ClassAbilityController(this);
|
||||
connectionHandler = new PlayerConnectionHandler(this);
|
||||
abilityPacketFilter = new AbilityPacketFilter(classAbilityController);
|
||||
|
||||
try {
|
||||
bootstrap = new Bootstrap(this);
|
||||
bootstrap.initialize();
|
||||
} catch (Exception e) {
|
||||
getLogger().at(Level.SEVERE).log("MMORPG bootstrap failed: %s", e.getMessage());
|
||||
throw new IllegalStateException("Failed to initialize MMORPG plugin", e);
|
||||
}
|
||||
|
||||
getCodecRegistry(Interaction.CODEC).register(
|
||||
MmorpgCastAbilityInteraction.ID,
|
||||
MmorpgCastAbilityInteraction.class,
|
||||
MmorpgCastAbilityInteraction.CODEC);
|
||||
|
||||
getCommandRegistry().registerCommand(new MmorpgCommand(this));
|
||||
getEventRegistry().registerGlobal(PlayerReadyEvent.class, connectionHandler::onPlayerReady);
|
||||
getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, connectionHandler::onPlayerDisconnect);
|
||||
inboundPacketFilter = PacketAdapters.registerInbound(abilityPacketFilter);
|
||||
|
||||
registerGameplaySystems();
|
||||
|
||||
getLogger().at(Level.INFO).log("MMORPG plugin setup complete");
|
||||
}
|
||||
|
||||
private void registerGameplaySystems() {
|
||||
ServiceRegistry registry = bootstrap.getRegistry();
|
||||
PlayerSessionService sessions = registry.require(PlayerSessionService.class);
|
||||
CombatStateService combatState = registry.require(CombatStateService.class);
|
||||
CombatBuffService combatBuffs = registry.require(CombatBuffService.class);
|
||||
PoisonService poisonService = registry.require(PoisonService.class);
|
||||
OnlinePlayerRegistry onlinePlayers = registry.require(OnlinePlayerRegistry.class);
|
||||
ProgressionService progression = registry.require(ProgressionService.class);
|
||||
GroupService groups = registry.require(GroupService.class);
|
||||
JobService jobs = registry.require(JobService.class);
|
||||
EconomyService economy = registry.require(EconomyService.class);
|
||||
int killExperience = bootstrap.getConfig().getKillExperience();
|
||||
|
||||
getEntityStoreRegistry().registerSystem(
|
||||
new MmorpgDamageSystem(sessions, combatState, combatBuffs, poisonService, onlinePlayers));
|
||||
getEntityStoreRegistry().registerSystem(
|
||||
new MmorpgDeathSystem(sessions, progression, groups, jobs, economy, killExperience));
|
||||
getEntityStoreRegistry().registerSystem(new MinerBlockBreakSystem(sessions, jobs, economy));
|
||||
|
||||
OpenCustomUIInteraction.registerSimple(this, PlayerInfoPage.class, "mmorpg_player_info", playerRef -> {
|
||||
PlayerProfile profile = sessions.getSession(playerRef.getUuid());
|
||||
if (profile == null) {
|
||||
profile = new PlayerProfile(playerRef.getUuid(), playerRef.getUsername(), System.currentTimeMillis());
|
||||
}
|
||||
return new PlayerInfoPage(playerRef, profile);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void start() {
|
||||
if (bootstrap != null) {
|
||||
bootstrap.startServices();
|
||||
}
|
||||
getLogger().at(Level.INFO).log("MMORPG plugin started");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void shutdown() {
|
||||
if (inboundPacketFilter != null) {
|
||||
PacketAdapters.deregisterInbound(inboundPacketFilter);
|
||||
inboundPacketFilter = null;
|
||||
}
|
||||
if (bootstrap != null) {
|
||||
bootstrap.shutdownServices();
|
||||
bootstrap = null;
|
||||
}
|
||||
instance = null;
|
||||
getLogger().at(Level.INFO).log("MMORPG plugin shut down");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Public API interfaces for cross-module integration (future extraction to separate plugins).
|
||||
*/
|
||||
package com.disklexar.mmorpg.api;
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.disklexar.mmorpg.bootstrap;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.combat.AbilityService;
|
||||
import com.disklexar.mmorpg.combat.CombatBuffService;
|
||||
import com.disklexar.mmorpg.combat.CombatStateService;
|
||||
import com.disklexar.mmorpg.combat.MmorpgScheduler;
|
||||
import com.disklexar.mmorpg.combat.PassiveEffectService;
|
||||
import com.disklexar.mmorpg.combat.PoisonService;
|
||||
import com.disklexar.mmorpg.combat.StunService;
|
||||
import com.disklexar.mmorpg.core.config.MmorpgConfig;
|
||||
import com.disklexar.mmorpg.economy.EconomyService;
|
||||
import com.disklexar.mmorpg.persistence.DatabaseManager;
|
||||
import com.disklexar.mmorpg.persistence.repository.GroupRepository;
|
||||
import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.progression.ProgressionService;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassService;
|
||||
import com.disklexar.mmorpg.rpg.group.GroupService;
|
||||
import com.disklexar.mmorpg.rpg.job.JobService;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerService;
|
||||
import com.disklexar.mmorpg.rpg.race.RaceService;
|
||||
import com.disklexar.mmorpg.ui.AbilityBarService;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.io.IOException;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Wires and starts all MMORPG services.
|
||||
*/
|
||||
public final class Bootstrap {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
private final HytaleLogger logger;
|
||||
private final ServiceRegistry registry = new ServiceRegistry();
|
||||
private MmorpgConfig config;
|
||||
|
||||
public Bootstrap(@Nonnull MmorpgPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
this.logger = plugin.getLogger();
|
||||
}
|
||||
|
||||
public void initialize() throws IOException {
|
||||
config = MmorpgConfig.load(plugin.getDataDirectory());
|
||||
|
||||
// Persistence (DatabaseManager must start first so repositories can query).
|
||||
DatabaseManager databaseManager = new DatabaseManager(plugin, config);
|
||||
PlayerProfileRepository profileRepository = new PlayerProfileRepository(databaseManager);
|
||||
GroupRepository groupRepository = new GroupRepository(databaseManager);
|
||||
|
||||
// Stateless domain services.
|
||||
ProgressionService progression = new ProgressionService(config.getBaseXpPerLevel());
|
||||
EconomyService economy = new EconomyService();
|
||||
ClassService classService = new ClassService();
|
||||
PowerService powerService = new PowerService();
|
||||
JobService jobService = new JobService();
|
||||
RaceService raceService = new RaceService();
|
||||
CombatStateService combatState = new CombatStateService();
|
||||
CombatBuffService combatBuffs = new CombatBuffService();
|
||||
StunService stunService = new StunService();
|
||||
OnlinePlayerRegistry onlinePlayers = new OnlinePlayerRegistry();
|
||||
|
||||
// Session and lifecycle services.
|
||||
PlayerSessionService sessionService =
|
||||
new PlayerSessionService(plugin, config, profileRepository);
|
||||
MmorpgScheduler scheduler = new MmorpgScheduler(plugin);
|
||||
PoisonService poisonService = new PoisonService(scheduler);
|
||||
GroupService groupService =
|
||||
new GroupService(plugin, groupRepository, sessionService, progression);
|
||||
AbilityService abilityService =
|
||||
new AbilityService(scheduler, stunService, combatState, combatBuffs);
|
||||
PassiveEffectService passiveEffects =
|
||||
new PassiveEffectService(plugin, scheduler, onlinePlayers, sessionService, combatState);
|
||||
AbilityBarService abilityBarService = new AbilityBarService(
|
||||
plugin, classService, abilityService, onlinePlayers, sessionService, progression, scheduler);
|
||||
|
||||
registry.register(MmorpgConfig.class, config);
|
||||
registry.register(DatabaseManager.class, databaseManager);
|
||||
registry.register(PlayerProfileRepository.class, profileRepository);
|
||||
registry.register(GroupRepository.class, groupRepository);
|
||||
registry.register(ProgressionService.class, progression);
|
||||
registry.register(EconomyService.class, economy);
|
||||
registry.register(ClassService.class, classService);
|
||||
registry.register(PowerService.class, powerService);
|
||||
registry.register(JobService.class, jobService);
|
||||
registry.register(RaceService.class, raceService);
|
||||
registry.register(CombatStateService.class, combatState);
|
||||
registry.register(CombatBuffService.class, combatBuffs);
|
||||
registry.register(PoisonService.class, poisonService);
|
||||
registry.register(StunService.class, stunService);
|
||||
registry.register(OnlinePlayerRegistry.class, onlinePlayers);
|
||||
registry.register(PlayerSessionService.class, sessionService);
|
||||
registry.register(MmorpgScheduler.class, scheduler);
|
||||
registry.register(GroupService.class, groupService);
|
||||
registry.register(AbilityService.class, abilityService);
|
||||
registry.register(AbilityBarService.class, abilityBarService);
|
||||
registry.register(PassiveEffectService.class, passiveEffects);
|
||||
|
||||
logger.at(Level.INFO).log("MMORPG bootstrap initialized");
|
||||
}
|
||||
|
||||
public void startServices() {
|
||||
registry.startAll();
|
||||
}
|
||||
|
||||
public void shutdownServices() {
|
||||
registry.shutdownAll();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public ServiceRegistry getRegistry() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public MmorpgConfig getConfig() {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.disklexar.mmorpg.bootstrap;
|
||||
|
||||
import com.disklexar.mmorpg.core.service.Service;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Simple service locator for MMORPG components.
|
||||
*/
|
||||
public final class ServiceRegistry {
|
||||
|
||||
private final Map<Class<?>, Object> services = new HashMap<>();
|
||||
private final List<Service> lifecycleServices = new ArrayList<>();
|
||||
|
||||
public <T> void register(@Nonnull Class<T> type, @Nonnull T instance) {
|
||||
services.put(type, instance);
|
||||
if (instance instanceof Service service) {
|
||||
lifecycleServices.add(service);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public <T> T get(@Nonnull Class<T> type) {
|
||||
return type.cast(services.get(type));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public <T> T require(@Nonnull Class<T> type) {
|
||||
T service = get(type);
|
||||
if (service == null) {
|
||||
throw new IllegalStateException("Service not registered: " + type.getName());
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
public void startAll() {
|
||||
for (Service service : lifecycleServices) {
|
||||
service.start();
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdownAll() {
|
||||
for (int i = lifecycleServices.size() - 1; i >= 0; i--) {
|
||||
lifecycleServices.get(i).shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
|
||||
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.HeadRotation;
|
||||
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
|
||||
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
|
||||
import com.hypixel.hytale.server.core.modules.physics.component.Velocity;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.server.core.util.TargetUtil;
|
||||
import org.joml.Vector3d;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Executes the active class abilities. Effects are applied with the verified server APIs
|
||||
* ({@link TargetUtil}, {@link DamageSystems#executeDamage}, {@link Velocity}); exact numbers
|
||||
* (dash speed, radii, damage) are tuned via {@code config.json}-style constants and may need
|
||||
* in-game adjustment.
|
||||
*/
|
||||
public final class AbilityService {
|
||||
|
||||
// Knockback impulse for the forward dash. The vertical component matters a lot: too little and
|
||||
// ground friction kills the lunge instantly (the previous ~0.5 block issue). Tunable.
|
||||
private static final double DASH_KNOCKBACK = 16.0;
|
||||
private static final double DASH_KNOCKBACK_Y = 4.0;
|
||||
private static final double DASH_RADIUS = 2.5;
|
||||
private static final long DASH_SCAN_PERIOD = 100L;
|
||||
private static final int DASH_SCANS = 5;
|
||||
private static final long STUN_DURATION = 3_000L;
|
||||
|
||||
private static final double SPIN_RADIUS = 3.0;
|
||||
private static final float SPIN_DAMAGE = 3.0f;
|
||||
private static final long SPIN_DURATION = 3_000L;
|
||||
private static final long SPIN_PERIOD = 300L;
|
||||
|
||||
private static final double SHOUT_RADIUS = 5.0;
|
||||
private static final double SHOUT_KNOCKBACK = 18.0;
|
||||
private static final double SHOUT_KNOCKBACK_Y = 4.0;
|
||||
private static final float SHOUT_STUNNED_BONUS_DAMAGE = 6.0f;
|
||||
private static final long RESISTANCE_DEBUFF_DURATION = 20_000L;
|
||||
|
||||
// --- Archer ---
|
||||
private static final long RAPID_FIRE_DURATION = 10_000L;
|
||||
private static final int VOLLEY_ARROWS = 6;
|
||||
private static final double VOLLEY_SPREAD_DEG = 9.0;
|
||||
private static final float VOLLEY_ARROW_DAMAGE = 4.0f;
|
||||
private static final double VOLLEY_RANGE = 18.0;
|
||||
private static final double VOLLEY_BACK_KNOCKBACK = 12.0;
|
||||
private static final double VOLLEY_BACK_KNOCKBACK_Y = 3.0;
|
||||
|
||||
// --- Mage ---
|
||||
private static final int FIREBALL_COUNT = 3;
|
||||
private static final double FIREBALL_SPREAD_DEG = 12.0;
|
||||
private static final float FIREBALL_DAMAGE = 7.0f;
|
||||
private static final double FIREBALL_RANGE = 20.0;
|
||||
private static final double FIREBALL_HIT_RADIUS = 1.6;
|
||||
private static final double TELEPORT_DISTANCE = 8.0;
|
||||
private static final double TORNADO_DISTANCE = 15.0;
|
||||
private static final double TORNADO_RADIUS = 3.5;
|
||||
private static final float TORNADO_DAMAGE = 4.0f;
|
||||
private static final double TORNADO_PULL = 6.0;
|
||||
private static final long TORNADO_DURATION = 4_000L;
|
||||
private static final long TORNADO_PERIOD = 350L;
|
||||
|
||||
// --- Assassin ---
|
||||
private static final long CAMOUFLAGE_DURATION = 6_000L;
|
||||
private static final double SHADOWSTEP_RANGE = 12.0;
|
||||
private static final double SHADOWSTEP_BEHIND = 1.5;
|
||||
private static final long POISON_BLADE_DURATION = 10_000L;
|
||||
|
||||
// Projectile / spell visuals (shipped game particle systems; unknown ids fail silently).
|
||||
private static final String VFX_ARROW_TRAIL = "Bow_Signature_Projectile_Sparks";
|
||||
private static final String VFX_ARROW_HIT = "Example_Projectile_Impact";
|
||||
private static final String VFX_FIRE_TRAIL = "Fire_Charged1";
|
||||
private static final String VFX_FIRE_HIT = "Impact_Explosion";
|
||||
private static final String VFX_TELEPORT = "Teleport";
|
||||
private static final String VFX_TORNADO = "Example_Tornado";
|
||||
private static final String VFX_TORNADO_HIT = "Impact_Battleaxe_Bash";
|
||||
private static final String VFX_SMOKE = "Smoke_Black";
|
||||
private static final String VFX_BUFF = "Test_Cast_Buff";
|
||||
private static final String SFX_BOW = "SFX_Bow_T1_Shoot";
|
||||
private static final String SFX_FIRE = "SFX_Staff_Flame_Fireball_Launch";
|
||||
private static final String SFX_TELEPORT = "SFX_Portal_Neutral";
|
||||
|
||||
// Visual / audio asset ids (shipped game assets). See CombatFx.
|
||||
private static final String VFX_DASH = "Daggers_Signature_Dash";
|
||||
private static final String VFX_HIT = "Impact_Blade_01";
|
||||
private static final String VFX_SPIN = "Battleaxe_Signature_Whirlwind";
|
||||
private static final String VFX_SPIN_HIT = "Impact_Battleaxe_Bash";
|
||||
private static final String VFX_SHOUT = "Impact_Explosion";
|
||||
|
||||
private static final String SFX_ACTIVATE = "SFX_Avatar_Powers_Enable";
|
||||
private static final String SFX_SWING = "SFX_Golem_Earth_Swing";
|
||||
private static final String SFX_HIT = "SFX_Golem_Earth_Swing_Impact";
|
||||
private static final String SFX_SHOUT = "SFX_Deer_Stag_Roar";
|
||||
private static final String SFX_SHOUT_IMPACT = "SFX_Golem_Earth_Slam_Impact";
|
||||
|
||||
// Weapon animation moves (see Server/Item/Animations/{Battleaxe,Longsword}.json).
|
||||
private static final String ANIM_SET_BATTLEAXE = "Battleaxe";
|
||||
private static final String ANIM_SET_LONGSWORD = "Longsword";
|
||||
private static final String ANIM_DASH = "SwingDown";
|
||||
private static final String ANIM_SHOUT = "GuardBash";
|
||||
private static final String ANIM_SPIN_BATTLEAXE = "WhirlwindChargedSpin";
|
||||
private static final String ANIM_SPIN_LONGSWORD_A = "SwingLeft";
|
||||
private static final String ANIM_SPIN_LONGSWORD_B = "SwingRight";
|
||||
|
||||
private final MmorpgScheduler scheduler;
|
||||
private final StunService stunService;
|
||||
private final CombatStateService combatState;
|
||||
private final CombatBuffService combatBuffs;
|
||||
|
||||
private final Map<UUID, Map<String, Long>> cooldowns = new ConcurrentHashMap<>();
|
||||
|
||||
public AbilityService(
|
||||
@Nonnull MmorpgScheduler scheduler,
|
||||
@Nonnull StunService stunService,
|
||||
@Nonnull CombatStateService combatState,
|
||||
@Nonnull CombatBuffService combatBuffs) {
|
||||
this.scheduler = scheduler;
|
||||
this.stunService = stunService;
|
||||
this.combatState = combatState;
|
||||
this.combatBuffs = combatBuffs;
|
||||
}
|
||||
|
||||
public long cooldownRemaining(@Nonnull UUID player, @Nonnull String abilityId) {
|
||||
Map<String, Long> map = cooldowns.get(player);
|
||||
if (map == null) {
|
||||
return 0L;
|
||||
}
|
||||
Long readyAt = map.get(abilityId);
|
||||
if (readyAt == null) {
|
||||
return 0L;
|
||||
}
|
||||
return Math.max(0L, readyAt - System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to cast {@code ability} for the player. The caller is responsible for verifying the
|
||||
* player owns the class; this method enforces the weapon requirement and the cooldown.
|
||||
*/
|
||||
@Nonnull
|
||||
public CastResult cast(
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull World world,
|
||||
@Nonnull PlayerClass playerClass,
|
||||
@Nonnull AbilityDefinition ability) {
|
||||
return cast(playerRef, world, playerClass, ability, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts {@code ability}. {@code aimedTarget} is the entity the player is currently looking at
|
||||
* (supplied by the input-action handler, used by abilities such as the assassin's Shadow Step);
|
||||
* it may be {@code null} when triggered from the command, in which case the target is resolved
|
||||
* from the caster's aim.
|
||||
*/
|
||||
@Nonnull
|
||||
public CastResult cast(
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull World world,
|
||||
@Nonnull PlayerClass playerClass,
|
||||
@Nonnull AbilityDefinition ability,
|
||||
@Nullable Ref<EntityStore> aimedTarget) {
|
||||
UUID caster = playerRef.getUuid();
|
||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||
Ref<EntityStore> casterRef = playerRef.getReference();
|
||||
|
||||
if (!hasRequiredWeapon(store, casterRef, playerClass)) {
|
||||
return CastResult.WRONG_WEAPON;
|
||||
}
|
||||
if (cooldownRemaining(caster, ability.id()) > 0) {
|
||||
return CastResult.ON_COOLDOWN;
|
||||
}
|
||||
|
||||
String weaponSet = resolveWeaponAnimSet(store, casterRef);
|
||||
switch (ability.id()) {
|
||||
case ClassCatalog.ABILITY_DASH -> castDash(world, store, casterRef, weaponSet);
|
||||
case ClassCatalog.ABILITY_SPIN -> castSpin(world, store, casterRef, weaponSet);
|
||||
case ClassCatalog.ABILITY_SHOUT -> castShout(world, store, casterRef, weaponSet);
|
||||
|
||||
case ClassCatalog.ABILITY_ARCHER_RAPIDFIRE -> castRapidFire(world, store, casterRef, caster);
|
||||
case ClassCatalog.ABILITY_ARCHER_VOLLEY -> castVolley(world, store, casterRef);
|
||||
case ClassCatalog.ABILITY_ARCHER_POWERSHOT -> castPowerShot(world, store, casterRef, caster);
|
||||
|
||||
case ClassCatalog.ABILITY_MAGE_FIREBALLS -> castFireballs(world, store, casterRef);
|
||||
case ClassCatalog.ABILITY_MAGE_TELEPORT -> castTeleport(world, store, casterRef);
|
||||
case ClassCatalog.ABILITY_MAGE_TORNADO -> castTornado(world, store, casterRef);
|
||||
|
||||
case ClassCatalog.ABILITY_ASSASSIN_CAMOUFLAGE -> castCamouflage(world, store, casterRef, playerRef);
|
||||
case ClassCatalog.ABILITY_ASSASSIN_SHADOWSTEP -> castShadowStep(world, store, casterRef, aimedTarget);
|
||||
case ClassCatalog.ABILITY_ASSASSIN_POISONBLADE -> castPoisonBlade(world, store, casterRef, caster);
|
||||
default -> {
|
||||
return CastResult.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
combatState.markCombat(caster);
|
||||
cooldowns.computeIfAbsent(caster, k -> new ConcurrentHashMap<>())
|
||||
.put(ability.id(), System.currentTimeMillis() + ability.cooldownMillis());
|
||||
return CastResult.OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the currently held weapon to its animation set id ({@code Battleaxe} / {@code Longsword}),
|
||||
* so we can trigger the matching swing/whirlwind move. Returns {@code null} if it cannot be
|
||||
* resolved (the animation step is then skipped, effects still play).
|
||||
*/
|
||||
private String resolveWeaponAnimSet(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef) {
|
||||
try {
|
||||
ItemStack held = InventoryComponent.getItemInHand(store, casterRef);
|
||||
if (ItemStack.isEmpty(held)) {
|
||||
return null;
|
||||
}
|
||||
String itemId = held.getItemId();
|
||||
if (itemId == null) {
|
||||
return null;
|
||||
}
|
||||
String lower = itemId.toLowerCase();
|
||||
if (lower.contains("battleaxe") || (lower.contains("axe") && !lower.contains("pickaxe"))) {
|
||||
return ANIM_SET_BATTLEAXE;
|
||||
}
|
||||
return ANIM_SET_LONGSWORD;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasRequiredWeapon(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull PlayerClass playerClass) {
|
||||
try {
|
||||
ItemStack held = InventoryComponent.getItemInHand(store, casterRef);
|
||||
if (ItemStack.isEmpty(held)) {
|
||||
return false;
|
||||
}
|
||||
String itemId = held.getItemId();
|
||||
if (itemId == null) {
|
||||
return false;
|
||||
}
|
||||
for (String weapon : playerClass.weapons()) {
|
||||
if (itemId.toLowerCase().contains(weapon.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Throwable t) {
|
||||
// If the inventory API shape differs at runtime, fail open so the ability is still usable.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void castDash(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
String weaponSet) {
|
||||
world.execute(() -> {
|
||||
Vector3d direction = horizontalLookDirection(store, casterRef);
|
||||
// Player movement is client-authoritative, so push the caster via the engine knockback
|
||||
// system (syncs to the client) instead of writing Velocity directly.
|
||||
CombatFx.knockback(store, casterRef, new Vector3d(
|
||||
direction.x * DASH_KNOCKBACK, DASH_KNOCKBACK_Y, direction.z * DASH_KNOCKBACK));
|
||||
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
|
||||
Vector3d pos = transform != null ? transform.getPosition() : new Vector3d();
|
||||
CombatFx.animation(store, casterRef, weaponSet, ANIM_DASH);
|
||||
CombatFx.vfx(store, VFX_DASH, pos);
|
||||
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.2f);
|
||||
CombatFx.sound(store, SFX_SWING, pos);
|
||||
});
|
||||
|
||||
// Stun every enemy the dash passes through over its travel window.
|
||||
for (int scan = 1; scan <= DASH_SCANS; scan++) {
|
||||
scheduler.runLater(() -> world.execute(() -> {
|
||||
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
|
||||
if (transform == null) {
|
||||
return;
|
||||
}
|
||||
Vector3d pos = transform.getPosition();
|
||||
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(
|
||||
pos, DASH_RADIUS, store)) {
|
||||
if (target.equals(casterRef)) {
|
||||
continue;
|
||||
}
|
||||
applyStun(world, store, target);
|
||||
dealDamage(store, casterRef, target, 2.0f);
|
||||
CombatFx.vfx(store, VFX_HIT, targetPosition(store, target, pos));
|
||||
CombatFx.sound(store, SFX_HIT, pos);
|
||||
}
|
||||
}), (long) scan * DASH_SCAN_PERIOD);
|
||||
}
|
||||
}
|
||||
|
||||
private void castSpin(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
String weaponSet) {
|
||||
String spinAnimA = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_A;
|
||||
String spinAnimB = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_B;
|
||||
java.util.concurrent.atomic.AtomicInteger tick = new java.util.concurrent.atomic.AtomicInteger();
|
||||
|
||||
var future = scheduler.runRepeating(() -> world.execute(() -> {
|
||||
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
|
||||
if (transform == null) {
|
||||
return;
|
||||
}
|
||||
Vector3d pos = transform.getPosition();
|
||||
int n = tick.getAndIncrement();
|
||||
// Keep the whirlwind spinning visually and audibly for its whole duration. Each puff is
|
||||
// spawned with a short, explicit lifetime so it follows the player and fully disappears
|
||||
// when the spin ends (otherwise the looping aura would stay on screen forever).
|
||||
CombatFx.animation(store, casterRef, weaponSet, (n % 2 == 0) ? spinAnimA : spinAnimB);
|
||||
CombatFx.vfxTimed(store, VFX_SPIN, pos, 1.0f, (SPIN_PERIOD / 1000.0f) * 1.2f);
|
||||
CombatFx.sound(store, SFX_SWING, pos, 0.8f, 1.1f);
|
||||
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(pos, SPIN_RADIUS, store)) {
|
||||
if (target.equals(casterRef)) {
|
||||
continue;
|
||||
}
|
||||
dealDamage(store, casterRef, target, SPIN_DAMAGE);
|
||||
CombatFx.vfx(store, VFX_SPIN_HIT, targetPosition(store, target, pos));
|
||||
}
|
||||
}), 0L, SPIN_PERIOD);
|
||||
scheduler.runLater(() -> future.cancel(false), SPIN_DURATION);
|
||||
}
|
||||
|
||||
private void castShout(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
String weaponSet) {
|
||||
world.execute(() -> {
|
||||
TransformComponent casterTransform = store.getComponent(casterRef, TransformComponent.getComponentType());
|
||||
if (casterTransform == null) {
|
||||
return;
|
||||
}
|
||||
Vector3d origin = casterTransform.getPosition();
|
||||
// Fallback push direction (caster's facing) for targets standing right on top of the
|
||||
// player, where origin-to-target has no usable direction.
|
||||
Vector3d facing = horizontalLookDirection(store, casterRef);
|
||||
CombatFx.animation(store, casterRef, weaponSet, ANIM_SHOUT);
|
||||
CombatFx.vfx(store, VFX_SHOUT, origin);
|
||||
CombatFx.sound(store, SFX_SHOUT, origin, 1.2f, 0.9f);
|
||||
CombatFx.sound(store, SFX_SHOUT_IMPACT, origin);
|
||||
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(origin, SHOUT_RADIUS, store)) {
|
||||
if (target.equals(casterRef)) {
|
||||
continue;
|
||||
}
|
||||
TransformComponent targetTransform =
|
||||
store.getComponent(target, TransformComponent.getComponentType());
|
||||
if (targetTransform != null) {
|
||||
Vector3d push = new Vector3d(targetTransform.getPosition()).sub(origin);
|
||||
push.y = 0;
|
||||
if (push.lengthSquared() < 1.0e-4) {
|
||||
// Mob glued to the player: shove it away along the caster's look direction.
|
||||
push.set(facing.x, 0, facing.z);
|
||||
}
|
||||
if (push.lengthSquared() < 1.0e-4) {
|
||||
push.set(0, 0, 1);
|
||||
}
|
||||
push.normalize();
|
||||
// Client-synced knockback so both players and mobs are actually pushed back.
|
||||
CombatFx.knockback(store, target, new Vector3d(
|
||||
push.x * SHOUT_KNOCKBACK, SHOUT_KNOCKBACK_Y, push.z * SHOUT_KNOCKBACK));
|
||||
}
|
||||
|
||||
UUID targetUuid = entityUuid(store, target);
|
||||
if (targetUuid != null) {
|
||||
if (stunService.isStunned(targetUuid)) {
|
||||
dealDamage(store, casterRef, target, SHOUT_STUNNED_BONUS_DAMAGE);
|
||||
}
|
||||
// -20% resistance == takes +20% damage for 20s (applied in the damage system).
|
||||
combatState.applyResistanceDebuff(targetUuid, RESISTANCE_DEBUFF_DURATION);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Archer
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
private void castRapidFire(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull UUID caster) {
|
||||
combatBuffs.grantRapidFire(caster, RAPID_FIRE_DURATION);
|
||||
world.execute(() -> {
|
||||
Vector3d pos = position(store, casterRef);
|
||||
CombatFx.vfx(store, VFX_BUFF, pos);
|
||||
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.4f);
|
||||
});
|
||||
}
|
||||
|
||||
private void castVolley(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef) {
|
||||
world.execute(() -> {
|
||||
Vector3d facing = horizontalLookDirection(store, casterRef);
|
||||
// Bond en arrière.
|
||||
CombatFx.knockback(store, casterRef, new Vector3d(
|
||||
-facing.x * VOLLEY_BACK_KNOCKBACK, VOLLEY_BACK_KNOCKBACK_Y, -facing.z * VOLLEY_BACK_KNOCKBACK));
|
||||
Vector3d eye = eyePosition(store, casterRef);
|
||||
CombatFx.sound(store, SFX_BOW, eye, 1.0f, 1.1f);
|
||||
double start = -(VOLLEY_ARROWS - 1) / 2.0;
|
||||
for (int i = 0; i < VOLLEY_ARROWS; i++) {
|
||||
Vector3d dir = rotatedHorizontal(facing, (start + i) * VOLLEY_SPREAD_DEG);
|
||||
fireProjectile(store, casterRef, eye, dir, VOLLEY_RANGE, 1.2,
|
||||
VOLLEY_ARROW_DAMAGE, VFX_ARROW_TRAIL, VFX_ARROW_HIT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void castPowerShot(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull UUID caster) {
|
||||
combatBuffs.chargePowerShot(caster);
|
||||
world.execute(() -> {
|
||||
Vector3d pos = position(store, casterRef);
|
||||
CombatFx.vfx(store, VFX_BUFF, pos);
|
||||
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.8f);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Mage
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
private void castFireballs(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef) {
|
||||
world.execute(() -> {
|
||||
Vector3d facing = horizontalLookDirection(store, casterRef);
|
||||
Vector3d eye = eyePosition(store, casterRef);
|
||||
CombatFx.sound(store, SFX_FIRE, eye, 1.1f, 1.0f);
|
||||
double start = -(FIREBALL_COUNT - 1) / 2.0;
|
||||
for (int i = 0; i < FIREBALL_COUNT; i++) {
|
||||
Vector3d dir = rotatedHorizontal(facing, (start + i) * FIREBALL_SPREAD_DEG);
|
||||
fireProjectile(store, casterRef, eye, dir, FIREBALL_RANGE, FIREBALL_HIT_RADIUS,
|
||||
FIREBALL_DAMAGE, VFX_FIRE_TRAIL, VFX_FIRE_HIT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void castTeleport(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef) {
|
||||
world.execute(() -> {
|
||||
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
|
||||
if (transform == null) {
|
||||
return;
|
||||
}
|
||||
Vector3d from = new Vector3d(transform.getPosition());
|
||||
Vector3d dir = horizontalLookDirection(store, casterRef);
|
||||
Vector3d to = new Vector3d(from).add(dir.x * TELEPORT_DISTANCE, 0, dir.z * TELEPORT_DISTANCE);
|
||||
CombatFx.vfx(store, VFX_TELEPORT, from);
|
||||
CombatFx.sound(store, SFX_TELEPORT, from, 1.0f, 1.0f);
|
||||
transform.teleportPosition(to);
|
||||
CombatFx.vfx(store, VFX_TELEPORT, to);
|
||||
CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.2f);
|
||||
});
|
||||
}
|
||||
|
||||
private void castTornado(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef) {
|
||||
Vector3d[] center = new Vector3d[1];
|
||||
world.execute(() -> {
|
||||
Vector3d eye = eyePosition(store, casterRef);
|
||||
Vector3d dir = horizontalLookDirection(store, casterRef);
|
||||
center[0] = new Vector3d(eye).add(dir.x * TORNADO_DISTANCE, -1.0, dir.z * TORNADO_DISTANCE);
|
||||
CombatFx.sound(store, SFX_FIRE, center[0], 1.3f, 0.7f);
|
||||
});
|
||||
var future = scheduler.runRepeating(() -> world.execute(() -> {
|
||||
if (center[0] == null) {
|
||||
return;
|
||||
}
|
||||
Vector3d pos = center[0];
|
||||
CombatFx.vfxTimed(store, VFX_TORNADO, pos, 1.5f, (TORNADO_PERIOD / 1000.0f) * 1.3f);
|
||||
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(pos, TORNADO_RADIUS, store)) {
|
||||
if (target.equals(casterRef)) {
|
||||
continue;
|
||||
}
|
||||
Vector3d tpos = targetPosition(store, target, pos);
|
||||
Vector3d pull = new Vector3d(pos).sub(tpos);
|
||||
pull.y = 0;
|
||||
if (pull.lengthSquared() > 1.0e-4) {
|
||||
pull.normalize();
|
||||
}
|
||||
CombatFx.knockback(store, target, new Vector3d(
|
||||
pull.x * TORNADO_PULL, 2.5, pull.z * TORNADO_PULL));
|
||||
dealDamage(store, casterRef, target, TORNADO_DAMAGE);
|
||||
CombatFx.vfx(store, VFX_TORNADO_HIT, tpos);
|
||||
}
|
||||
}), 0L, TORNADO_PERIOD);
|
||||
scheduler.runLater(() -> future.cancel(false), TORNADO_DURATION);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Assassin
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
private void castCamouflage(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull PlayerRef playerRef) {
|
||||
UUID caster = playerRef.getUuid();
|
||||
combatBuffs.markInvisible(caster, CAMOUFLAGE_DURATION);
|
||||
world.execute(() -> {
|
||||
Vector3d pos = position(store, casterRef);
|
||||
CombatFx.vfx(store, VFX_SMOKE, pos);
|
||||
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.7f);
|
||||
for (PlayerRef viewer : world.getPlayerRefs()) {
|
||||
try {
|
||||
viewer.getHiddenPlayersManager().hidePlayer(caster);
|
||||
} catch (Throwable ignored) {
|
||||
// viewer not ready: skip.
|
||||
}
|
||||
}
|
||||
});
|
||||
scheduler.runLater(() -> world.execute(() -> {
|
||||
for (PlayerRef viewer : world.getPlayerRefs()) {
|
||||
try {
|
||||
viewer.getHiddenPlayersManager().showPlayer(caster);
|
||||
} catch (Throwable ignored) {
|
||||
// viewer gone: skip.
|
||||
}
|
||||
}
|
||||
CombatFx.vfx(store, VFX_SMOKE, position(store, casterRef));
|
||||
}), CAMOUFLAGE_DURATION);
|
||||
}
|
||||
|
||||
private void castShadowStep(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nullable Ref<EntityStore> aimedTarget) {
|
||||
world.execute(() -> {
|
||||
Ref<EntityStore> target = aimedTarget;
|
||||
if (target == null || !target.isValid() || target.equals(casterRef)) {
|
||||
target = TargetUtil.getTargetEntity(casterRef, (float) SHADOWSTEP_RANGE, store);
|
||||
}
|
||||
TransformComponent casterTransform =
|
||||
store.getComponent(casterRef, TransformComponent.getComponentType());
|
||||
if (casterTransform == null) {
|
||||
return;
|
||||
}
|
||||
if (target == null || !target.isValid() || target.equals(casterRef)) {
|
||||
// No target in sight: short blink forward instead of failing outright.
|
||||
Vector3d dir = horizontalLookDirection(store, casterRef);
|
||||
Vector3d from = new Vector3d(casterTransform.getPosition());
|
||||
Vector3d to = new Vector3d(from).add(dir.x * 3.0, 0, dir.z * 3.0);
|
||||
CombatFx.vfx(store, VFX_TELEPORT, from);
|
||||
casterTransform.teleportPosition(to);
|
||||
CombatFx.vfx(store, VFX_TELEPORT, to);
|
||||
CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.0f);
|
||||
return;
|
||||
}
|
||||
TransformComponent targetTransform =
|
||||
store.getComponent(target, TransformComponent.getComponentType());
|
||||
if (targetTransform == null) {
|
||||
return;
|
||||
}
|
||||
Vector3d targetPos = new Vector3d(targetTransform.getPosition());
|
||||
Vector3d targetFacing = horizontalLookDirection(store, target);
|
||||
Vector3d behind = new Vector3d(targetPos)
|
||||
.sub(targetFacing.x * SHADOWSTEP_BEHIND, 0, targetFacing.z * SHADOWSTEP_BEHIND);
|
||||
Vector3d from = new Vector3d(casterTransform.getPosition());
|
||||
CombatFx.vfx(store, VFX_SMOKE, from);
|
||||
casterTransform.teleportPosition(behind);
|
||||
CombatFx.vfx(store, VFX_TELEPORT, behind);
|
||||
CombatFx.sound(store, SFX_TELEPORT, behind, 1.0f, 0.9f);
|
||||
});
|
||||
}
|
||||
|
||||
private void castPoisonBlade(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull UUID caster) {
|
||||
combatBuffs.grantPoisonBlade(caster, POISON_BLADE_DURATION);
|
||||
world.execute(() -> {
|
||||
Vector3d pos = position(store, casterRef);
|
||||
CombatFx.vfx(store, "Impact_Poison", pos);
|
||||
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.9f);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Shared helpers for the new abilities
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Casts a straight projectile by stepping along {@code dir} from {@code origin}, leaving a
|
||||
* particle streak and damaging the first entity within {@code hitRadius} of the path. A
|
||||
* server-side approximation of a real arrow/fireball (no engine projectile entity is spawned),
|
||||
* but it reads as a projectile and deals damage.
|
||||
*/
|
||||
private void fireProjectile(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull Vector3d origin,
|
||||
@Nonnull Vector3d dir,
|
||||
double range,
|
||||
double hitRadius,
|
||||
float damage,
|
||||
@Nonnull String trailVfx,
|
||||
@Nonnull String hitVfx) {
|
||||
Vector3d point = new Vector3d(origin);
|
||||
Vector3d step = new Vector3d(dir).mul(1.0);
|
||||
int steps = (int) Math.ceil(range);
|
||||
for (int i = 0; i < steps; i++) {
|
||||
point.add(step);
|
||||
CombatFx.vfxTimed(store, trailVfx, new Vector3d(point), 0.5f, 0.35f);
|
||||
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(point, hitRadius, store)) {
|
||||
if (target.equals(casterRef)) {
|
||||
continue;
|
||||
}
|
||||
dealDamage(store, casterRef, target, damage);
|
||||
CombatFx.vfx(store, hitVfx, targetPosition(store, target, point));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Vector3d rotatedHorizontal(@Nonnull Vector3d base, double degrees) {
|
||||
Vector3d v = new Vector3d(base);
|
||||
v.y = 0;
|
||||
v.rotateY((float) Math.toRadians(degrees));
|
||||
if (v.lengthSquared() < 1.0e-4) {
|
||||
return new Vector3d(0, 0, 1);
|
||||
}
|
||||
return v.normalize();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Vector3d eyePosition(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
return new Vector3d(position(store, ref)).add(0, 1.5, 0);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Vector3d position(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType());
|
||||
return transform != null ? new Vector3d(transform.getPosition()) : new Vector3d();
|
||||
}
|
||||
|
||||
private void applyStun(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> target) {
|
||||
UUID uuid = entityUuid(store, target);
|
||||
if (uuid != null) {
|
||||
if (stunService.isStunned(uuid)) {
|
||||
return;
|
||||
}
|
||||
stunService.stun(uuid, STUN_DURATION);
|
||||
}
|
||||
var future = scheduler.runRepeating(() -> world.execute(() -> {
|
||||
Velocity velocity = store.getComponent(target, Velocity.getComponentType());
|
||||
if (velocity != null) {
|
||||
velocity.setZero();
|
||||
}
|
||||
}), 0L, 100L);
|
||||
scheduler.runLater(() -> future.cancel(false), STUN_DURATION);
|
||||
}
|
||||
|
||||
private void dealDamage(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull Ref<EntityStore> target,
|
||||
float amount) {
|
||||
Damage damage = new Damage(new Damage.EntitySource(casterRef), DamageCause.PHYSICAL, amount);
|
||||
DamageSystems.executeDamage(target, store, damage);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Vector3d horizontalLookDirection(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef) {
|
||||
// Use the HEAD rotation (precise camera yaw), not the body TransformComponent rotation which
|
||||
// is coarse/axis-snapped — that snapping made the dash align to X/Z instead of the true aim.
|
||||
// This mirrors how the engine itself derives a knockback direction (see KnockbackApplyCommand).
|
||||
Float yaw = null;
|
||||
HeadRotation head = store.getComponent(casterRef, HeadRotation.getComponentType());
|
||||
if (head != null && head.getRotation() != null) {
|
||||
yaw = head.getRotation().yaw();
|
||||
} else {
|
||||
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
|
||||
if (transform != null && transform.getRotation() != null) {
|
||||
yaw = transform.getRotation().yaw();
|
||||
}
|
||||
}
|
||||
if (yaw == null) {
|
||||
return new Vector3d(0, 0, 1);
|
||||
}
|
||||
Vector3d direction = new Vector3d(0, 0, -1).rotateY(yaw);
|
||||
direction.y = 0;
|
||||
if (direction.lengthSquared() < 1.0e-4) {
|
||||
return new Vector3d(0, 0, 1);
|
||||
}
|
||||
return direction.normalize();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Vector3d targetPosition(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> target,
|
||||
@Nonnull Vector3d fallback) {
|
||||
TransformComponent transform = store.getComponent(target, TransformComponent.getComponentType());
|
||||
return transform != null ? transform.getPosition() : fallback;
|
||||
}
|
||||
|
||||
private UUID entityUuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
return component == null ? null : component.getUuid();
|
||||
}
|
||||
|
||||
public enum CastResult {
|
||||
OK, ON_COOLDOWN, WRONG_WEAPON, UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.bootstrap.Bootstrap;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassService;
|
||||
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.protocol.InteractionType;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Central entry point for routing Ability 1/2/3 inputs to MMORPG class abilities.
|
||||
* Used by the packet filter, native {@code mmorpg_cast_ability} interaction, and commands.
|
||||
*/
|
||||
public final class ClassAbilityController {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
public ClassAbilityController(@Nonnull MmorpgPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code true} when the player has an MMORPG class and vanilla ability interactions
|
||||
* should be suppressed for this slot (even if the cast fails, e.g. wrong weapon).
|
||||
*/
|
||||
public boolean shouldSuppressVanilla(@Nonnull PlayerRef playerRef, int slot) {
|
||||
return resolveAbility(playerRef, slot) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to cast the class ability bound to {@code slot}.
|
||||
*
|
||||
* @return {@code true} if vanilla should be blocked (player has a class with this slot).
|
||||
*/
|
||||
public boolean tryCast(
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull World world,
|
||||
int slot,
|
||||
@Nullable Ref<EntityStore> aimedTarget) {
|
||||
ResolvedAbility resolved = resolveAbility(playerRef, slot);
|
||||
if (resolved == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AbilityService.CastResult result = resolved.abilities().cast(
|
||||
playerRef, world, resolved.playerClass(), resolved.ability(), aimedTarget);
|
||||
|
||||
switch (result) {
|
||||
case OK -> playerRef.sendMessage(Message.raw("Capacité : " + resolved.ability().displayName()));
|
||||
case ON_COOLDOWN -> {
|
||||
long remaining = resolved.abilities().cooldownRemaining(
|
||||
playerRef.getUuid(), resolved.ability().id());
|
||||
playerRef.sendMessage(Message.raw("En recharge (" + (remaining / 1000 + 1) + "s)."));
|
||||
}
|
||||
case WRONG_WEAPON -> playerRef.sendMessage(Message.raw(
|
||||
"Mauvaise arme. Équipez : " + String.join(", ", resolved.playerClass().weapons())));
|
||||
case UNKNOWN -> playerRef.sendMessage(Message.raw("Capacité inconnue."));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int slotFor(@Nonnull InteractionType type) {
|
||||
return switch (type) {
|
||||
case Ability1 -> 1;
|
||||
case Ability2 -> 2;
|
||||
case Ability3 -> 3;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean isClassAbilityType(@Nonnull InteractionType type) {
|
||||
return slotFor(type) != 0;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private ResolvedAbility resolveAbility(@Nonnull PlayerRef playerRef, int slot) {
|
||||
Bootstrap bootstrap = plugin.getBootstrap();
|
||||
if (bootstrap == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
|
||||
ClassService classService = bootstrap.getRegistry().require(ClassService.class);
|
||||
AbilityService abilities = bootstrap.getRegistry().require(AbilityService.class);
|
||||
|
||||
PlayerProfile profile = sessions.getSession(playerRef.getUuid());
|
||||
if (profile == null) {
|
||||
return null;
|
||||
}
|
||||
PlayerClass playerClass = classService.getClass(profile);
|
||||
if (playerClass == null) {
|
||||
return null;
|
||||
}
|
||||
AbilityDefinition ability = playerClass.abilities().stream()
|
||||
.filter(a -> a.slot() == slot)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (ability == null) {
|
||||
return null;
|
||||
}
|
||||
return new ResolvedAbility(playerClass, ability, abilities);
|
||||
}
|
||||
|
||||
private record ResolvedAbility(
|
||||
PlayerClass playerClass,
|
||||
AbilityDefinition ability,
|
||||
AbilityService abilities) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Tracks the temporary offensive buffs granted by class abilities and read back by the damage
|
||||
* system when the buffed player lands a basic attack:
|
||||
* <ul>
|
||||
* <li>Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for
|
||||
* attack speed, which is otherwise client-authoritative),</li>
|
||||
* <li>Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,</li>
|
||||
* <li>Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,</li>
|
||||
* <li>Assassin "Camouflage": bookkeeping of the current invisibility window.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class CombatBuffService {
|
||||
|
||||
private final Map<UUID, Long> rapidFireUntil = new ConcurrentHashMap<>();
|
||||
private final Set<UUID> powerShotCharged = ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, Long> poisonBladeUntil = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> invisibleUntil = new ConcurrentHashMap<>();
|
||||
|
||||
public void grantRapidFire(@Nonnull UUID uuid, long durationMillis) {
|
||||
rapidFireUntil.put(uuid, System.currentTimeMillis() + durationMillis);
|
||||
}
|
||||
|
||||
public boolean hasRapidFire(@Nonnull UUID uuid) {
|
||||
Long until = rapidFireUntil.get(uuid);
|
||||
return until != null && System.currentTimeMillis() < until;
|
||||
}
|
||||
|
||||
public void chargePowerShot(@Nonnull UUID uuid) {
|
||||
powerShotCharged.add(uuid);
|
||||
}
|
||||
|
||||
public boolean hasPowerShot(@Nonnull UUID uuid) {
|
||||
return powerShotCharged.contains(uuid);
|
||||
}
|
||||
|
||||
public boolean consumePowerShot(@Nonnull UUID uuid) {
|
||||
return powerShotCharged.remove(uuid);
|
||||
}
|
||||
|
||||
public void grantPoisonBlade(@Nonnull UUID uuid, long durationMillis) {
|
||||
poisonBladeUntil.put(uuid, System.currentTimeMillis() + durationMillis);
|
||||
}
|
||||
|
||||
public boolean hasPoisonBlade(@Nonnull UUID uuid) {
|
||||
Long until = poisonBladeUntil.get(uuid);
|
||||
return until != null && System.currentTimeMillis() < until;
|
||||
}
|
||||
|
||||
public void markInvisible(@Nonnull UUID uuid, long durationMillis) {
|
||||
invisibleUntil.put(uuid, System.currentTimeMillis() + durationMillis);
|
||||
}
|
||||
|
||||
public boolean isInvisible(@Nonnull UUID uuid) {
|
||||
Long until = invisibleUntil.get(uuid);
|
||||
return until != null && System.currentTimeMillis() < until;
|
||||
}
|
||||
|
||||
public void clear(@Nonnull UUID uuid) {
|
||||
rapidFireUntil.remove(uuid);
|
||||
powerShotCharged.remove(uuid);
|
||||
poisonBladeUntil.remove(uuid);
|
||||
invisibleUntil.remove(uuid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.protocol.AnimationSlot;
|
||||
import com.hypixel.hytale.protocol.ChangeVelocityType;
|
||||
import com.hypixel.hytale.protocol.SoundCategory;
|
||||
import com.hypixel.hytale.protocol.VelocityThresholdStyle;
|
||||
import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent;
|
||||
import com.hypixel.hytale.server.core.entity.AnimationUtils;
|
||||
import com.hypixel.hytale.server.core.entity.knockback.KnockbackComponent;
|
||||
import com.hypixel.hytale.server.core.modules.splitvelocity.VelocityConfig;
|
||||
import com.hypixel.hytale.server.core.universe.world.ParticleUtil;
|
||||
import com.hypixel.hytale.server.core.universe.world.SoundUtil;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import org.joml.Vector3d;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Thin, failure-safe wrappers around the verified server effect helpers
|
||||
* ({@link ParticleUtil}, {@link SoundUtil}, {@link AnimationUtils}). Each call is guarded so a
|
||||
* missing asset id or a runtime API mismatch can never break the ability or disconnect the player —
|
||||
* the worst case is simply that one effect is skipped.
|
||||
* <p>
|
||||
* Asset ids (particle systems / sound events / weapon animation moves) are taken from the shipped
|
||||
* game assets, e.g. {@code Battleaxe_Signature_Whirlwind}, {@code Impact_Explosion},
|
||||
* {@code SFX_Deer_Stag_Roar}, animation move {@code WhirlwindChargedSpin}.
|
||||
*/
|
||||
public final class CombatFx {
|
||||
|
||||
private CombatFx() {
|
||||
}
|
||||
|
||||
/** Spawns a world particle system (broadcast to nearby players) at a position. */
|
||||
public static void vfx(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull String particleSystemId,
|
||||
@Nonnull Vector3d position) {
|
||||
try {
|
||||
ParticleUtil.spawnParticleEffect(particleSystemId, new Vector3d(position), store);
|
||||
} catch (Throwable ignored) {
|
||||
// Unknown particle id or API shape mismatch: skip the effect silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a particle system with an explicit lifetime ({@code durationSeconds}) so it is
|
||||
* guaranteed to disappear — used for looping/aura effects (e.g. the whirlwind) that would
|
||||
* otherwise stay on screen forever.
|
||||
*/
|
||||
public static void vfxTimed(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull String particleSystemId,
|
||||
@Nonnull Vector3d position,
|
||||
float scale,
|
||||
float durationSeconds) {
|
||||
try {
|
||||
ParticleUtil.spawnParticleEffect(
|
||||
particleSystemId, new Vector3d(position), 0f, 0f, 0f, scale, durationSeconds, store);
|
||||
} catch (Throwable ignored) {
|
||||
// Unknown particle id or API shape mismatch: skip the effect silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a client-synced knockback/impulse to an entity (player or mob) via the engine's
|
||||
* {@link KnockbackComponent}. This is the only reliable way to move a <em>player</em> from the
|
||||
* server: directly writing {@code Velocity} is ignored because player movement is
|
||||
* client-authoritative. Must be called on the world thread.
|
||||
*/
|
||||
public static void knockback(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> entity,
|
||||
@Nonnull Vector3d velocity) {
|
||||
try {
|
||||
KnockbackComponent kb = store.ensureAndGetComponent(entity, KnockbackComponent.getComponentType());
|
||||
kb.setVelocity(new Vector3d(velocity));
|
||||
kb.setVelocityType(ChangeVelocityType.Set);
|
||||
kb.setVelocityConfig(defaultVelocityConfig());
|
||||
kb.setDuration(0f);
|
||||
kb.setTimer(0f);
|
||||
} catch (Throwable ignored) {
|
||||
// Knockback API mismatch: skip silently (ability still deals damage / plays effects).
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static VelocityConfig defaultVelocityConfig() {
|
||||
// Mirrors the engine's default knockback decay preset (see KnockbackApplyCommand).
|
||||
VelocityConfig config = new VelocityConfig();
|
||||
config.setAirResistance(0.99f);
|
||||
config.setAirResistanceMax(0.98f);
|
||||
config.setGroundResistance(0.94f);
|
||||
config.setGroundResistanceMax(0.3f);
|
||||
config.setThreshold(3.0f);
|
||||
config.setStyle(VelocityThresholdStyle.Linear);
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Plays a 3D sound event (broadcast to nearby players) at a position. */
|
||||
public static void sound(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull String soundEventId,
|
||||
@Nonnull Vector3d position) {
|
||||
sound(store, soundEventId, position, 1.0f, 1.0f);
|
||||
}
|
||||
|
||||
public static void sound(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull String soundEventId,
|
||||
@Nonnull Vector3d position,
|
||||
float volume,
|
||||
float pitch) {
|
||||
try {
|
||||
int index = SoundEvent.getAssetMap().getIndexOrDefault(soundEventId, -1);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
SoundUtil.playSoundEvent3d(
|
||||
index, SoundCategory.SFX, position.x, position.y, position.z, volume, pitch, store);
|
||||
} catch (Throwable ignored) {
|
||||
// Unknown sound id or API shape mismatch: skip the sound silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a weapon animation move on the entity (e.g. {@code WhirlwindChargedSpin}). The move must
|
||||
* belong to the supplied weapon animation set ({@code Battleaxe} / {@code Longsword}); a null set
|
||||
* skips the animation.
|
||||
*/
|
||||
public static void animation(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> entity,
|
||||
@Nullable String weaponAnimationSet,
|
||||
@Nonnull String animationMove) {
|
||||
if (weaponAnimationSet == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
AnimationUtils.playAnimation(
|
||||
entity, AnimationSlot.Action, weaponAnimationSet, animationMove, store);
|
||||
} catch (Throwable ignored) {
|
||||
// Animation set/move not loaded for this entity: skip silently.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Tracks lightweight combat state per player:
|
||||
* <ul>
|
||||
* <li>last time the player took damage (drives the "Résilient" power and out-of-combat detection),</li>
|
||||
* <li>temporary resistance debuffs applied by abilities (e.g. the Knight war cry).</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class CombatStateService {
|
||||
|
||||
/** A player is considered "in combat" for this long after taking or dealing damage. */
|
||||
public static final long IN_COMBAT_WINDOW_MILLIS = 6_000L;
|
||||
|
||||
private final Map<UUID, Long> lastDamageTakenAt = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> lastCombatAt = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> resistanceDebuffUntil = new ConcurrentHashMap<>();
|
||||
|
||||
public void markDamageTaken(@Nonnull UUID uuid) {
|
||||
long now = System.currentTimeMillis();
|
||||
lastDamageTakenAt.put(uuid, now);
|
||||
lastCombatAt.put(uuid, now);
|
||||
}
|
||||
|
||||
public void markCombat(@Nonnull UUID uuid) {
|
||||
lastCombatAt.put(uuid, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public boolean wasRecentlyHit(@Nonnull UUID uuid, long windowMillis) {
|
||||
Long at = lastDamageTakenAt.get(uuid);
|
||||
return at != null && System.currentTimeMillis() - at <= windowMillis;
|
||||
}
|
||||
|
||||
public boolean isInCombat(@Nonnull UUID uuid) {
|
||||
Long at = lastCombatAt.get(uuid);
|
||||
return at != null && System.currentTimeMillis() - at <= IN_COMBAT_WINDOW_MILLIS;
|
||||
}
|
||||
|
||||
public void applyResistanceDebuff(@Nonnull UUID uuid, long durationMillis) {
|
||||
resistanceDebuffUntil.put(uuid, System.currentTimeMillis() + durationMillis);
|
||||
}
|
||||
|
||||
public boolean hasResistanceDebuff(@Nonnull UUID uuid) {
|
||||
Long until = resistanceDebuffUntil.get(uuid);
|
||||
return until != null && System.currentTimeMillis() < until;
|
||||
}
|
||||
|
||||
public void clear(@Nonnull UUID uuid) {
|
||||
lastDamageTakenAt.remove(uuid);
|
||||
lastCombatAt.remove(uuid);
|
||||
resistanceDebuffUntil.remove(uuid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.core.service.Service;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* A small dedicated scheduler for timed gameplay effects (ability ticks, buff reconciliation).
|
||||
* <p>
|
||||
* The scheduler runs on its own thread; callbacks that touch ECS state must hop onto the world
|
||||
* thread via {@link com.hypixel.hytale.server.core.universe.world.World#execute(Runnable)}.
|
||||
*/
|
||||
public final class MmorpgScheduler implements Service {
|
||||
|
||||
private final HytaleLogger logger;
|
||||
private ScheduledExecutorService executor;
|
||||
|
||||
public MmorpgScheduler(@Nonnull MmorpgPlugin plugin) {
|
||||
this.logger = plugin.getLogger();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
executor = Executors.newScheduledThreadPool(2, runnable -> {
|
||||
Thread thread = new Thread(runnable, "mmorpg-scheduler");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
executor = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void runLater(@Nonnull Runnable task, long delayMillis) {
|
||||
if (executor == null) {
|
||||
return;
|
||||
}
|
||||
executor.schedule(wrap(task), Math.max(0L, delayMillis), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public ScheduledFuture<?> runRepeating(@Nonnull Runnable task, long initialDelayMillis, long periodMillis) {
|
||||
return executor.scheduleAtFixedRate(
|
||||
wrap(task), Math.max(0L, initialDelayMillis), Math.max(1L, periodMillis), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Runnable wrap(@Nonnull Runnable task) {
|
||||
return () -> {
|
||||
try {
|
||||
task.run();
|
||||
} catch (Throwable t) {
|
||||
logger.at(Level.WARNING).log("MMORPG scheduled task failed: %s", t.getMessage());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.core.service.Service;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayer;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.protocol.MovementSettings;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
|
||||
/**
|
||||
* Reconciles passive movement effects every couple of seconds.
|
||||
* <p>
|
||||
* The "Sprinter" power grants {@code +10%} base movement speed while the player is out of combat;
|
||||
* the bonus is removed while in combat and restored afterwards.
|
||||
*/
|
||||
public final class PassiveEffectService implements Service {
|
||||
|
||||
private static final long RECONCILE_PERIOD = 2_000L;
|
||||
|
||||
private final MmorpgScheduler scheduler;
|
||||
private final OnlinePlayerRegistry online;
|
||||
private final PlayerSessionService sessions;
|
||||
private final CombatStateService combatState;
|
||||
|
||||
private ScheduledFuture<?> task;
|
||||
|
||||
public PassiveEffectService(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull MmorpgScheduler scheduler,
|
||||
@Nonnull OnlinePlayerRegistry online,
|
||||
@Nonnull PlayerSessionService sessions,
|
||||
@Nonnull CombatStateService combatState) {
|
||||
this.scheduler = scheduler;
|
||||
this.online = online;
|
||||
this.sessions = sessions;
|
||||
this.combatState = combatState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
task = scheduler.runRepeating(this::reconcileAll, RECONCILE_PERIOD, RECONCILE_PERIOD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
if (task != null) {
|
||||
task.cancel(false);
|
||||
task = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void reconcileAll() {
|
||||
for (OnlinePlayer player : online.all()) {
|
||||
PlayerProfile profile = sessions.getSession(player.uuid());
|
||||
if (profile == null) {
|
||||
continue;
|
||||
}
|
||||
boolean sprinter = profile.getPowers().contains(PowerCatalog.SPRINTER.id());
|
||||
boolean active = sprinter && !combatState.isInCombat(player.uuid());
|
||||
player.world().execute(() -> applySpeed(player, active));
|
||||
}
|
||||
}
|
||||
|
||||
private void applySpeed(@Nonnull OnlinePlayer player, boolean buffed) {
|
||||
Store<EntityStore> store = player.world().getEntityStore().getStore();
|
||||
Ref<EntityStore> ref = player.entityRef();
|
||||
MovementManager movement = store.getComponent(ref, MovementManager.getComponentType());
|
||||
if (movement == null) {
|
||||
return;
|
||||
}
|
||||
MovementSettings defaults = movement.getDefaultSettings();
|
||||
MovementSettings current = movement.getSettings();
|
||||
if (defaults == null || current == null) {
|
||||
return;
|
||||
}
|
||||
float baseline = defaults.baseSpeed;
|
||||
float target = buffed ? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS) : baseline;
|
||||
if (Math.abs(current.baseSpeed - target) > 0.0001f) {
|
||||
current.baseSpeed = target;
|
||||
movement.update(player.playerRef().getPacketHandler());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import org.joml.Vector3d;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Applies a damage-over-time poison to a target. Each application is independent (so the assassin's
|
||||
* "Lame Empoisonnée" stacks without limit): calling {@link #applyPoison} again simply schedules
|
||||
* another series of ticks on top of the existing ones.
|
||||
*/
|
||||
public final class PoisonService {
|
||||
|
||||
private static final String VFX_POISON = "Impact_Poison";
|
||||
|
||||
private final MmorpgScheduler scheduler;
|
||||
|
||||
public PoisonService(@Nonnull MmorpgScheduler scheduler) {
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deals {@code damagePerTick} magic damage to {@code victim} once per {@code periodMillis},
|
||||
* {@code ticks} times. The caster keeps the kill credit via the damage source.
|
||||
*/
|
||||
public void applyPoison(
|
||||
@Nonnull World world,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> caster,
|
||||
@Nonnull Ref<EntityStore> victim,
|
||||
int ticks,
|
||||
float damagePerTick,
|
||||
long periodMillis) {
|
||||
AtomicInteger remaining = new AtomicInteger(ticks);
|
||||
for (int i = 1; i <= ticks; i++) {
|
||||
scheduler.runLater(() -> world.execute(() -> {
|
||||
if (remaining.decrementAndGet() < 0) {
|
||||
return;
|
||||
}
|
||||
if (!victim.isValid()) {
|
||||
return;
|
||||
}
|
||||
// No dedicated "magic" cause exists; ENVIRONMENT keeps the tick from re-triggering
|
||||
// the on-hit (PHYSICAL) buff logic, so the poison cannot recursively re-poison.
|
||||
Damage damage = new Damage(new Damage.EntitySource(caster), DamageCause.ENVIRONMENT, damagePerTick);
|
||||
DamageSystems.executeDamage(victim, store, damage);
|
||||
TransformComponent transform =
|
||||
store.getComponent(victim, TransformComponent.getComponentType());
|
||||
if (transform != null) {
|
||||
CombatFx.vfx(store, VFX_POISON, new Vector3d(transform.getPosition()));
|
||||
}
|
||||
}), (long) i * periodMillis);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.disklexar.mmorpg.combat;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Tracks which entities are currently stunned (used so the Knight war cry can deal bonus damage
|
||||
* to already-stunned targets). The actual movement lock is enforced by repeatedly zeroing the
|
||||
* target's velocity for the stun duration.
|
||||
*/
|
||||
public final class StunService {
|
||||
|
||||
private final Map<UUID, Long> stunnedUntil = new ConcurrentHashMap<>();
|
||||
|
||||
public void stun(@Nonnull UUID entityUuid, long durationMillis) {
|
||||
stunnedUntil.put(entityUuid, System.currentTimeMillis() + durationMillis);
|
||||
}
|
||||
|
||||
public boolean isStunned(@Nonnull UUID entityUuid) {
|
||||
Long until = stunnedUntil.get(entityUuid);
|
||||
if (until == null) {
|
||||
return false;
|
||||
}
|
||||
if (System.currentTimeMillis() >= until) {
|
||||
stunnedUntil.remove(entityUuid);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.disklexar.mmorpg.combat.system;
|
||||
|
||||
import com.disklexar.mmorpg.economy.EconomyService;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.job.JobCatalog;
|
||||
import com.disklexar.mmorpg.rpg.job.JobService;
|
||||
import com.hypixel.hytale.component.ArchetypeChunk;
|
||||
import com.hypixel.hytale.component.CommandBuffer;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.component.query.Query;
|
||||
import com.hypixel.hytale.component.system.EntityEventSystem;
|
||||
import com.hypixel.hytale.server.core.event.events.ecs.BreakBlockEvent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Grants money to players with the "Mineur" job each time they break a block.
|
||||
*/
|
||||
public final class MinerBlockBreakSystem extends EntityEventSystem<EntityStore, BreakBlockEvent> {
|
||||
|
||||
private static final Query<EntityStore> QUERY = Query.any();
|
||||
|
||||
private final PlayerSessionService sessions;
|
||||
private final JobService jobs;
|
||||
private final EconomyService economy;
|
||||
|
||||
public MinerBlockBreakSystem(
|
||||
@Nonnull PlayerSessionService sessions,
|
||||
@Nonnull JobService jobs,
|
||||
@Nonnull EconomyService economy) {
|
||||
super(BreakBlockEvent.class);
|
||||
this.sessions = sessions;
|
||||
this.jobs = jobs;
|
||||
this.economy = economy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Query<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
int index,
|
||||
@Nonnull ArchetypeChunk<EntityStore> chunk,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@Nonnull BreakBlockEvent event) {
|
||||
Ref<EntityStore> breaker = chunk.getReferenceTo(index);
|
||||
PlayerRef player = store.getComponent(breaker, PlayerRef.getComponentType());
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
PlayerProfile profile = sessions.getSession(player.getUuid());
|
||||
if (profile == null) {
|
||||
return;
|
||||
}
|
||||
if (jobs.has(profile, JobCatalog.MINER.id())) {
|
||||
economy.deposit(profile, JobCatalog.MINER_REWARD);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.disklexar.mmorpg.combat.system;
|
||||
|
||||
import com.disklexar.mmorpg.combat.CombatBuffService;
|
||||
import com.disklexar.mmorpg.combat.CombatStateService;
|
||||
import com.disklexar.mmorpg.combat.PoisonService;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayer;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
|
||||
import com.hypixel.hytale.component.ArchetypeChunk;
|
||||
import com.hypixel.hytale.component.CommandBuffer;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.component.SystemGroup;
|
||||
import com.hypixel.hytale.component.query.Query;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Adjusts incoming damage according to MMORPG state and tracks combat for passive powers:
|
||||
* <ul>
|
||||
* <li>victims with an active resistance debuff (Knight war cry) take +20% damage,</li>
|
||||
* <li>attackers with the "Résilient" power deal +10% damage for 5s after being hit.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class MmorpgDamageSystem extends DamageEventSystem {
|
||||
|
||||
private static final Query<EntityStore> QUERY = Query.any();
|
||||
private static final double RESISTANCE_DEBUFF_FACTOR = 1.20;
|
||||
|
||||
// Archer "Tir rapide": flat outgoing-damage bonus (server proxy for attack speed).
|
||||
private static final double RAPID_FIRE_FACTOR = 1.20;
|
||||
// Archer "Tir puissant": +200% on the next basic attack.
|
||||
private static final double POWER_SHOT_FACTOR = 3.0;
|
||||
// Assassin "Lame Empoisonnée": stacking poison applied on each basic hit.
|
||||
private static final int POISON_TICKS = 5;
|
||||
private static final float POISON_DAMAGE_PER_TICK = 2.0f;
|
||||
private static final long POISON_PERIOD_MILLIS = 1_000L;
|
||||
|
||||
private final PlayerSessionService sessions;
|
||||
private final CombatStateService combatState;
|
||||
private final CombatBuffService combatBuffs;
|
||||
private final PoisonService poisonService;
|
||||
private final OnlinePlayerRegistry onlinePlayers;
|
||||
|
||||
public MmorpgDamageSystem(
|
||||
@Nonnull PlayerSessionService sessions,
|
||||
@Nonnull CombatStateService combatState,
|
||||
@Nonnull CombatBuffService combatBuffs,
|
||||
@Nonnull PoisonService poisonService,
|
||||
@Nonnull OnlinePlayerRegistry onlinePlayers) {
|
||||
this.sessions = sessions;
|
||||
this.combatState = combatState;
|
||||
this.combatBuffs = combatBuffs;
|
||||
this.poisonService = poisonService;
|
||||
this.onlinePlayers = onlinePlayers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Query<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SystemGroup<EntityStore> getGroup() {
|
||||
return DamageModule.get().getInspectDamageGroup();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
int index,
|
||||
@Nonnull ArchetypeChunk<EntityStore> chunk,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@Nonnull Damage damage) {
|
||||
Ref<EntityStore> victim = chunk.getReferenceTo(index);
|
||||
double amount = damage.getAmount();
|
||||
|
||||
UUID victimUuid = uuid(store, victim);
|
||||
if (victimUuid != null && combatState.hasResistanceDebuff(victimUuid)) {
|
||||
amount *= RESISTANCE_DEBUFF_FACTOR;
|
||||
}
|
||||
|
||||
if (damage.getSource() instanceof Damage.EntitySource source) {
|
||||
Ref<EntityStore> attackerRef = source.getRef();
|
||||
if (attackerRef != null) {
|
||||
PlayerRef attacker = store.getComponent(attackerRef, PlayerRef.getComponentType());
|
||||
if (attacker != null) {
|
||||
UUID attackerUuid = attacker.getUuid();
|
||||
combatState.markCombat(attackerUuid);
|
||||
PlayerProfile profile = sessions.getSession(attackerUuid);
|
||||
if (profile != null
|
||||
&& profile.getPowers().contains(PowerCatalog.RESILIENT.id())
|
||||
&& combatState.wasRecentlyHit(attackerUuid, PowerCatalog.RESILIENT_WINDOW_MILLIS)) {
|
||||
amount *= (1.0 + PowerCatalog.RESILIENT_DAMAGE_BONUS);
|
||||
}
|
||||
|
||||
// Offensive class buffs only apply to direct physical hits, never to the
|
||||
// damage-over-time / spell ticks our own abilities deal (those are MAGIC), so a
|
||||
// single sword swing doesn't waste the archer's charged shot, etc.
|
||||
if (damage.getCause() == DamageCause.PHYSICAL) {
|
||||
if (combatBuffs.hasRapidFire(attackerUuid)) {
|
||||
amount *= RAPID_FIRE_FACTOR;
|
||||
}
|
||||
if (combatBuffs.consumePowerShot(attackerUuid)) {
|
||||
amount *= POWER_SHOT_FACTOR;
|
||||
}
|
||||
if (combatBuffs.hasPoisonBlade(attackerUuid)) {
|
||||
OnlinePlayer online = onlinePlayers.get(attackerUuid);
|
||||
if (online != null) {
|
||||
poisonService.applyPoison(online.world(), store, attackerRef, victim,
|
||||
POISON_TICKS, POISON_DAMAGE_PER_TICK, POISON_PERIOD_MILLIS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(amount - damage.getAmount()) > 0.0001) {
|
||||
damage.setAmount((float) amount);
|
||||
}
|
||||
|
||||
PlayerRef victimPlayer = store.getComponent(victim, PlayerRef.getComponentType());
|
||||
if (victimPlayer != null) {
|
||||
combatState.markDamageTaken(victimPlayer.getUuid());
|
||||
}
|
||||
}
|
||||
|
||||
private UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
return component == null ? null : component.getUuid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.disklexar.mmorpg.combat.system;
|
||||
|
||||
import com.disklexar.mmorpg.economy.EconomyService;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.progression.ProgressionService;
|
||||
import com.disklexar.mmorpg.rpg.group.GroupService;
|
||||
import com.disklexar.mmorpg.rpg.job.JobCatalog;
|
||||
import com.disklexar.mmorpg.rpg.job.JobService;
|
||||
import com.hypixel.hytale.component.CommandBuffer;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.component.query.Query;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Rewards a player for killing a mob: kill XP (scaled by race), shared XP for group mates, and
|
||||
* money when the killer holds the "Tueur de monstre" job.
|
||||
*/
|
||||
public final class MmorpgDeathSystem extends DeathSystems.OnDeathSystem {
|
||||
|
||||
private static final Query<EntityStore> QUERY = Query.any();
|
||||
|
||||
private final PlayerSessionService sessions;
|
||||
private final ProgressionService progression;
|
||||
private final GroupService groups;
|
||||
private final JobService jobs;
|
||||
private final EconomyService economy;
|
||||
private final int killExperience;
|
||||
|
||||
public MmorpgDeathSystem(
|
||||
@Nonnull PlayerSessionService sessions,
|
||||
@Nonnull ProgressionService progression,
|
||||
@Nonnull GroupService groups,
|
||||
@Nonnull JobService jobs,
|
||||
@Nonnull EconomyService economy,
|
||||
int killExperience) {
|
||||
this.sessions = sessions;
|
||||
this.progression = progression;
|
||||
this.groups = groups;
|
||||
this.jobs = jobs;
|
||||
this.economy = economy;
|
||||
this.killExperience = Math.max(0, killExperience);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Query<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComponentAdded(
|
||||
@Nonnull Ref<EntityStore> deadRef,
|
||||
@Nonnull DeathComponent death,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
|
||||
// Only mobs award rewards; ignore player deaths.
|
||||
if (store.getComponent(deadRef, PlayerRef.getComponentType()) != null) {
|
||||
return;
|
||||
}
|
||||
Damage info = death.getDeathInfo();
|
||||
if (info == null || !(info.getSource() instanceof Damage.EntitySource source)) {
|
||||
return;
|
||||
}
|
||||
Ref<EntityStore> killerRef = source.getRef();
|
||||
if (killerRef == null) {
|
||||
return;
|
||||
}
|
||||
PlayerRef killer = store.getComponent(killerRef, PlayerRef.getComponentType());
|
||||
if (killer == null) {
|
||||
return;
|
||||
}
|
||||
UUID killerUuid = killer.getUuid();
|
||||
PlayerProfile profile = sessions.getSession(killerUuid);
|
||||
if (profile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long gained = progression.grantExperience(profile, killExperience, true);
|
||||
int shared = groups.shareKillExperience(killerUuid, gained);
|
||||
|
||||
long reward = 0L;
|
||||
if (jobs.has(profile, JobCatalog.MONSTER_SLAYER.id())) {
|
||||
reward = JobCatalog.MONSTER_SLAYER_REWARD;
|
||||
economy.deposit(profile, reward);
|
||||
}
|
||||
|
||||
StringBuilder message = new StringBuilder("Monstre vaincu — +" + gained + " XP");
|
||||
if (reward > 0) {
|
||||
message.append(" | +").append(reward).append(" money");
|
||||
}
|
||||
if (shared > 0) {
|
||||
message.append(" | XP partagée avec ").append(shared).append(" allié(s)");
|
||||
}
|
||||
killer.sendMessage(Message.raw(message.toString()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.combat.ClassAbilityController;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg ability <1|2|3>} — uses one of your class abilities.
|
||||
*/
|
||||
public final class AbilityCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<Integer> slotArg;
|
||||
|
||||
public AbilityCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("ability", "Utiliser une capacité de classe (slot 1, 2 ou 3)");
|
||||
this.plugin = plugin;
|
||||
this.slotArg = withRequiredArg("slot", "Numéro de la capacité (1-3)", ArgTypes.INTEGER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
ClassAbilityController controller = plugin.getClassAbilityController();
|
||||
int slot = context.get(slotArg);
|
||||
if (!controller.shouldSuppressVanilla(playerRef, slot)) {
|
||||
context.sendMessage(Message.raw("Vous n'avez pas de classe. Utilisez /mmorpg class choisir <classe>."));
|
||||
return;
|
||||
}
|
||||
controller.tryCast(playerRef, world, slot, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayer;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
|
||||
import com.disklexar.mmorpg.ui.AbilityBarService;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassService;
|
||||
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg class <choisir|quitter|info>}
|
||||
*/
|
||||
public final class ClassCommand extends AbstractCommandCollection {
|
||||
|
||||
public ClassCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("class", "Gérer votre classe");
|
||||
addSubCommand(new Choose(plugin));
|
||||
addSubCommand(new Leave(plugin));
|
||||
addSubCommand(new Info(plugin));
|
||||
}
|
||||
|
||||
private static final class Choose extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<String> classArg;
|
||||
|
||||
Choose(@Nonnull MmorpgPlugin plugin) {
|
||||
super("choisir", "Choisir une classe");
|
||||
this.plugin = plugin;
|
||||
this.classArg = withRequiredArg("classe", "Identifiant de la classe", ArgTypes.STRING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
ClassService classService = CommandSupport.service(plugin, ClassService.class);
|
||||
if (profile == null || classService == null) {
|
||||
context.sendMessage(Message.raw("Profil indisponible."));
|
||||
return;
|
||||
}
|
||||
String id = context.get(classArg);
|
||||
if (classService.choose(profile, id)) {
|
||||
PlayerClass chosen = classService.getClass(profile);
|
||||
context.sendMessage(Message.raw("Classe choisie : " + (chosen != null ? chosen.displayName() : id)));
|
||||
syncAbilityBar(plugin, playerRef, profile);
|
||||
} else {
|
||||
context.sendMessage(Message.raw("Classe inconnue. Disponibles : " + classList()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Leave extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
Leave(@Nonnull MmorpgPlugin plugin) {
|
||||
super("quitter", "Quitter votre classe");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
ClassService classService = CommandSupport.service(plugin, ClassService.class);
|
||||
if (profile == null || classService == null) {
|
||||
context.sendMessage(Message.raw("Profil indisponible."));
|
||||
return;
|
||||
}
|
||||
if (classService.leave(profile)) {
|
||||
context.sendMessage(Message.raw("Vous avez quitté votre classe."));
|
||||
dismissAbilityBar(plugin, playerRef);
|
||||
} else {
|
||||
context.sendMessage(Message.raw("Vous n'avez aucune classe."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Info extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
Info(@Nonnull MmorpgPlugin plugin) {
|
||||
super("info", "Informations sur les classes");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
ClassService classService = CommandSupport.service(plugin, ClassService.class);
|
||||
StringBuilder builder = new StringBuilder("=== Classes ===");
|
||||
if (profile != null && classService != null) {
|
||||
PlayerClass current = classService.getClass(profile);
|
||||
builder.append("\nClasse actuelle : ").append(current != null ? current.displayName() : "aucune");
|
||||
}
|
||||
for (PlayerClass playerClass : ClassCatalog.all()) {
|
||||
builder.append("\n- ").append(playerClass.displayName())
|
||||
.append(" (").append(playerClass.id()).append(") — ")
|
||||
.append(playerClass.description())
|
||||
.append("\n Armes : ").append(String.join(", ", playerClass.weapons()));
|
||||
for (AbilityDefinition ability : playerClass.abilities()) {
|
||||
builder.append("\n • Capacité ").append(ability.slot()).append(" — ")
|
||||
.append(ability.displayName()).append(" : ").append(ability.description());
|
||||
}
|
||||
}
|
||||
context.sendMessage(Message.raw(builder.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void syncAbilityBar(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull PlayerProfile profile) {
|
||||
AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class);
|
||||
OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class);
|
||||
if (abilityBar == null || onlinePlayers == null) {
|
||||
return;
|
||||
}
|
||||
OnlinePlayer online = onlinePlayers.get(playerRef.getUuid());
|
||||
if (online != null) {
|
||||
abilityBar.sync(online, profile);
|
||||
}
|
||||
}
|
||||
|
||||
private static void dismissAbilityBar(@Nonnull MmorpgPlugin plugin, @Nonnull PlayerRef playerRef) {
|
||||
AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class);
|
||||
if (abilityBar != null) {
|
||||
abilityBar.dismiss(playerRef.getUuid());
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String classList() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (PlayerClass playerClass : ClassCatalog.all()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(", ");
|
||||
}
|
||||
builder.append(playerClass.id());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.bootstrap.Bootstrap;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Shared helpers for MMORPG commands: service lookup and current-player profile resolution.
|
||||
*/
|
||||
final class CommandSupport {
|
||||
|
||||
private CommandSupport() {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static <T> T service(@Nonnull MmorpgPlugin plugin, @Nonnull Class<T> type) {
|
||||
Bootstrap bootstrap = plugin.getBootstrap();
|
||||
if (bootstrap == null) {
|
||||
return null;
|
||||
}
|
||||
return bootstrap.getRegistry().get(type);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
return component == null ? null : component.getUuid();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static PlayerProfile profile(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref) {
|
||||
UUID uuid = uuid(store, ref);
|
||||
if (uuid == null) {
|
||||
return null;
|
||||
}
|
||||
PlayerSessionService sessions = service(plugin, PlayerSessionService.class);
|
||||
return sessions == null ? null : sessions.getSession(uuid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.rpg.group.Group;
|
||||
import com.disklexar.mmorpg.rpg.group.GroupService;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg group <inviter|accepter|quitter|kick|info>}
|
||||
*/
|
||||
public final class GroupCommand extends AbstractCommandCollection {
|
||||
|
||||
public GroupCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("group", "Gérer votre groupe (partage d'XP)");
|
||||
addSubCommand(new Invite(plugin));
|
||||
addSubCommand(new Accept(plugin));
|
||||
addSubCommand(new Leave(plugin));
|
||||
addSubCommand(new Kick(plugin));
|
||||
addSubCommand(new Info(plugin));
|
||||
}
|
||||
|
||||
private static final class Invite extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<PlayerRef> targetArg;
|
||||
|
||||
Invite(@Nonnull MmorpgPlugin plugin) {
|
||||
super("inviter", "Inviter un joueur dans votre groupe");
|
||||
this.plugin = plugin;
|
||||
this.targetArg = withRequiredArg("joueur", "Joueur à inviter", ArgTypes.PLAYER_REF);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
GroupService groups = CommandSupport.service(plugin, GroupService.class);
|
||||
UUID inviter = CommandSupport.uuid(store, ref);
|
||||
if (groups == null || inviter == null) {
|
||||
context.sendMessage(Message.raw("Service de groupe indisponible."));
|
||||
return;
|
||||
}
|
||||
PlayerRef target = context.get(targetArg);
|
||||
switch (groups.invite(inviter, target.getUuid())) {
|
||||
case INVITED -> {
|
||||
context.sendMessage(Message.raw("Invitation envoyée à " + target.getUsername() + "."));
|
||||
target.sendMessage(Message.raw(playerRef.getUsername()
|
||||
+ " vous invite dans son groupe. Tapez /mmorpg group accepter"));
|
||||
}
|
||||
case SELF -> context.sendMessage(Message.raw("Vous ne pouvez pas vous inviter vous-même."));
|
||||
case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut inviter."));
|
||||
case TARGET_ALREADY_GROUPED ->
|
||||
context.sendMessage(Message.raw("Ce joueur est déjà dans un groupe."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Accept extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
Accept(@Nonnull MmorpgPlugin plugin) {
|
||||
super("accepter", "Accepter une invitation de groupe");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
GroupService groups = CommandSupport.service(plugin, GroupService.class);
|
||||
UUID uuid = CommandSupport.uuid(store, ref);
|
||||
if (groups == null || uuid == null) {
|
||||
context.sendMessage(Message.raw("Service de groupe indisponible."));
|
||||
return;
|
||||
}
|
||||
switch (groups.accept(uuid)) {
|
||||
case JOINED -> context.sendMessage(Message.raw("Vous avez rejoint le groupe."));
|
||||
case NO_INVITE -> context.sendMessage(Message.raw("Aucune invitation en attente."));
|
||||
case ALREADY_GROUPED -> context.sendMessage(Message.raw("Vous êtes déjà dans un groupe."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Leave extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
Leave(@Nonnull MmorpgPlugin plugin) {
|
||||
super("quitter", "Quitter votre groupe");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
GroupService groups = CommandSupport.service(plugin, GroupService.class);
|
||||
UUID uuid = CommandSupport.uuid(store, ref);
|
||||
if (groups == null || uuid == null) {
|
||||
context.sendMessage(Message.raw("Service de groupe indisponible."));
|
||||
return;
|
||||
}
|
||||
context.sendMessage(Message.raw(groups.leave(uuid)
|
||||
? "Vous avez quitté votre groupe."
|
||||
: "Vous n'êtes dans aucun groupe."));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Kick extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<PlayerRef> targetArg;
|
||||
|
||||
Kick(@Nonnull MmorpgPlugin plugin) {
|
||||
super("kick", "Exclure un joueur (créateur uniquement)");
|
||||
this.plugin = plugin;
|
||||
this.targetArg = withRequiredArg("joueur", "Joueur à exclure", ArgTypes.PLAYER_REF);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
GroupService groups = CommandSupport.service(plugin, GroupService.class);
|
||||
UUID owner = CommandSupport.uuid(store, ref);
|
||||
if (groups == null || owner == null) {
|
||||
context.sendMessage(Message.raw("Service de groupe indisponible."));
|
||||
return;
|
||||
}
|
||||
PlayerRef target = context.get(targetArg);
|
||||
switch (groups.kick(owner, target.getUuid())) {
|
||||
case KICKED -> {
|
||||
context.sendMessage(Message.raw(target.getUsername() + " a été exclu du groupe."));
|
||||
target.sendMessage(Message.raw("Vous avez été exclu du groupe."));
|
||||
}
|
||||
case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut exclure."));
|
||||
case NOT_MEMBER -> context.sendMessage(Message.raw("Ce joueur n'est pas dans votre groupe."));
|
||||
case CANNOT_KICK_SELF ->
|
||||
context.sendMessage(Message.raw("Utilisez /mmorpg group quitter pour partir."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Info extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
Info(@Nonnull MmorpgPlugin plugin) {
|
||||
super("info", "Informations sur votre groupe");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
GroupService groups = CommandSupport.service(plugin, GroupService.class);
|
||||
UUID uuid = CommandSupport.uuid(store, ref);
|
||||
if (groups == null || uuid == null) {
|
||||
context.sendMessage(Message.raw("Service de groupe indisponible."));
|
||||
return;
|
||||
}
|
||||
Group group = groups.getGroupOf(uuid);
|
||||
if (group == null) {
|
||||
context.sendMessage(Message.raw("Vous n'êtes dans aucun groupe. "
|
||||
+ "Le partage d'XP donne " + GroupService.SHARED_XP_FRACTION_PERCENT
|
||||
+ "% de l'XP d'un kill aux autres membres."));
|
||||
return;
|
||||
}
|
||||
context.sendMessage(Message.raw("=== Groupe ===\nCréateur : "
|
||||
+ (group.isOwner(uuid) ? "vous" : group.getOwner())
|
||||
+ "\nMembres : " + group.getMembers().size()
|
||||
+ "\nPartage d'XP : " + GroupService.SHARED_XP_FRACTION_PERCENT + "%"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayer;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassService;
|
||||
import com.disklexar.mmorpg.ui.AbilityBarService;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg hud} — force-affiche la barre de capacités (debug / test manuel).
|
||||
*/
|
||||
public final class HudCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
public HudCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("hud", "Afficher la barre de capacités MMORPG");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
if (profile == null) {
|
||||
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
|
||||
return;
|
||||
}
|
||||
|
||||
ClassService classService = CommandSupport.service(plugin, ClassService.class);
|
||||
AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class);
|
||||
OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class);
|
||||
if (classService == null || abilityBar == null || onlinePlayers == null) {
|
||||
context.sendMessage(Message.raw("Service HUD indisponible."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!classService.hasClass(profile)) {
|
||||
context.sendMessage(Message.raw("Choisissez d'abord une classe : /mmorpg class choisir <id>"));
|
||||
return;
|
||||
}
|
||||
|
||||
OnlinePlayer online = onlinePlayers.get(playerRef.getUuid());
|
||||
if (online == null) {
|
||||
context.sendMessage(Message.raw("Joueur non enregistré. Reconnectez-vous."));
|
||||
return;
|
||||
}
|
||||
|
||||
abilityBar.dismiss(online);
|
||||
abilityBar.sync(online, profile);
|
||||
context.sendMessage(Message.raw("Barre de capacités renvoyée (colonne gauche)."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.player.PlayerInfoView;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg info} — prints the full player profile in chat (reliable fallback for the UI).
|
||||
*/
|
||||
public final class InfoCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
public InfoCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("info", "Afficher toutes vos informations de joueur");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
if (profile == null) {
|
||||
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
|
||||
return;
|
||||
}
|
||||
context.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.job.JobCatalog;
|
||||
import com.disklexar.mmorpg.rpg.job.JobDefinition;
|
||||
import com.disklexar.mmorpg.rpg.job.JobService;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg job <rejoindre|quitter|info>}
|
||||
*/
|
||||
public final class JobCommand extends AbstractCommandCollection {
|
||||
|
||||
public JobCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("job", "Gérer vos métiers");
|
||||
addSubCommand(new Join(plugin));
|
||||
addSubCommand(new Leave(plugin));
|
||||
addSubCommand(new Info(plugin));
|
||||
}
|
||||
|
||||
private static final class Join extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<String> jobArg;
|
||||
|
||||
Join(@Nonnull MmorpgPlugin plugin) {
|
||||
super("rejoindre", "Rejoindre un métier");
|
||||
this.plugin = plugin;
|
||||
this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
JobService jobService = CommandSupport.service(plugin, JobService.class);
|
||||
if (profile == null || jobService == null) {
|
||||
context.sendMessage(Message.raw("Profil indisponible."));
|
||||
return;
|
||||
}
|
||||
String id = context.get(jobArg);
|
||||
JobDefinition job = JobCatalog.byId(id);
|
||||
if (job == null) {
|
||||
context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList()));
|
||||
return;
|
||||
}
|
||||
context.sendMessage(Message.raw(jobService.join(profile, id)
|
||||
? "Métier rejoint : " + job.displayName()
|
||||
: "Vous exercez déjà " + job.displayName() + "."));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Leave extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<String> jobArg;
|
||||
|
||||
Leave(@Nonnull MmorpgPlugin plugin) {
|
||||
super("quitter", "Quitter un métier");
|
||||
this.plugin = plugin;
|
||||
this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
JobService jobService = CommandSupport.service(plugin, JobService.class);
|
||||
if (profile == null || jobService == null) {
|
||||
context.sendMessage(Message.raw("Profil indisponible."));
|
||||
return;
|
||||
}
|
||||
String id = context.get(jobArg);
|
||||
JobDefinition job = JobCatalog.byId(id);
|
||||
if (job == null) {
|
||||
context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList()));
|
||||
return;
|
||||
}
|
||||
context.sendMessage(Message.raw(jobService.leave(profile, id)
|
||||
? "Métier quitté : " + job.displayName()
|
||||
: "Vous n'exercez pas " + job.displayName() + "."));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Info extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
Info(@Nonnull MmorpgPlugin plugin) {
|
||||
super("info", "Informations sur les métiers");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
StringBuilder builder = new StringBuilder("=== Métiers ===");
|
||||
for (JobDefinition job : JobCatalog.all()) {
|
||||
boolean active = profile != null && profile.getJobs().contains(job.id());
|
||||
builder.append("\n- ").append(job.displayName())
|
||||
.append(" (").append(job.id()).append(") ")
|
||||
.append(active ? "[actif]" : "")
|
||||
.append("\n ").append(job.description());
|
||||
}
|
||||
context.sendMessage(Message.raw(builder.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String jobList() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (JobDefinition job : JobCatalog.all()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(", ");
|
||||
}
|
||||
builder.append(job.id());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.player.PlayerInfoView;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.ui.PlayerInfoPage;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg menu} — opens the custom UI page listing all player info.
|
||||
* Falls back to the chat panel if the UI page cannot be opened.
|
||||
*/
|
||||
public final class MenuCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
public MenuCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("menu", "Ouvrir l'interface d'informations du joueur");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
if (profile == null) {
|
||||
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
|
||||
return;
|
||||
}
|
||||
Player player = store.getComponent(ref, Player.getComponentType());
|
||||
if (player == null) {
|
||||
context.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
|
||||
return;
|
||||
}
|
||||
PageManager pageManager = player.getPageManager();
|
||||
world.execute(() -> {
|
||||
try {
|
||||
pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, false));
|
||||
} catch (Throwable t) {
|
||||
playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.player.PlayerInfoView;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.ui.PlayerInfoPage;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg menucustom} — opens the fully custom player-info window backed by this plugin's
|
||||
* Opens the designed asset-pack page ({@code Pages/MmorpgPlayerInfo.ui}).
|
||||
*/
|
||||
public final class MenuCustomCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
public MenuCustomCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("menucustom", "Ouvrir l'interface joueur personnalisée (asset pack)");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
if (profile == null) {
|
||||
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
|
||||
return;
|
||||
}
|
||||
Player player = store.getComponent(ref, Player.getComponentType());
|
||||
if (player == null) {
|
||||
context.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
|
||||
return;
|
||||
}
|
||||
PageManager pageManager = player.getPageManager();
|
||||
world.execute(() -> {
|
||||
try {
|
||||
pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, true));
|
||||
} catch (Throwable t) {
|
||||
playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Root command: /mmorpg
|
||||
*/
|
||||
public final class MmorpgCommand extends AbstractCommandCollection {
|
||||
|
||||
public MmorpgCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("mmorpg", "Commandes du serveur MMORPG");
|
||||
addSubCommand(new MmorpgHelpCommand());
|
||||
addSubCommand(new MmorpgProfileCommand(plugin));
|
||||
addSubCommand(new InfoCommand(plugin));
|
||||
addSubCommand(new MenuCommand(plugin));
|
||||
addSubCommand(new HudCommand(plugin));
|
||||
addSubCommand(new ClassCommand(plugin));
|
||||
addSubCommand(new PowerCommand(plugin));
|
||||
addSubCommand(new JobCommand(plugin));
|
||||
addSubCommand(new GroupCommand(plugin));
|
||||
addSubCommand(new AbilityCommand(plugin));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* /mmorpg help
|
||||
*/
|
||||
public final class MmorpgHelpCommand extends AbstractAsyncCommand {
|
||||
|
||||
public MmorpgHelpCommand() {
|
||||
super("help", "Affiche l'aide MMORPG");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
protected CompletableFuture<Void> executeAsync(@Nonnull CommandContext context) {
|
||||
context.sendMessage(Message.raw("""
|
||||
=== MMORPG ===
|
||||
/mmorpg help — cette aide
|
||||
/mmorpg profile — résumé court de votre profil
|
||||
/mmorpg info — toutes vos informations (chat)
|
||||
/mmorpg menu — interface des informations du joueur
|
||||
/mmorpg menucustom — interface personnalisée (asset pack, test)
|
||||
/mmorpg class <choisir|quitter|info> [classe]
|
||||
/mmorpg power <recevoir|retirer|info> [pouvoir]
|
||||
/mmorpg job <rejoindre|quitter|info> [metier]
|
||||
/mmorpg group <inviter|accepter|quitter|kick|info> [joueur]
|
||||
/mmorpg ability <1|2|3> — utiliser une capacité de classe
|
||||
(ou utilisez directement les touches « Use ability 1/2/3 » en jeu)
|
||||
Classes : Chevalier, Archer, Mage, Assassin (voir /mmorpg class info)
|
||||
"""));
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.bootstrap.Bootstrap;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* /mmorpg profile
|
||||
*/
|
||||
public final class MmorpgProfileCommand extends AbstractPlayerCommand {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
public MmorpgProfileCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("profile", "Affiche votre profil MMORPG");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(
|
||||
@Nonnull CommandContext context,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull World world) {
|
||||
UUIDComponent uuidComponent = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
|
||||
if (uuidComponent == null) {
|
||||
context.sendMessage(Message.raw("Impossible de lire votre profil."));
|
||||
return;
|
||||
}
|
||||
|
||||
Bootstrap bootstrap = plugin.getBootstrap();
|
||||
if (bootstrap == null) {
|
||||
context.sendMessage(Message.raw("Le plugin MMORPG n'est pas encore prêt."));
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
|
||||
PlayerProfile profile = sessions.getSession(uuidComponent.getUuid());
|
||||
|
||||
if (profile == null) {
|
||||
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous au serveur."));
|
||||
return;
|
||||
}
|
||||
|
||||
context.sendMessage(Message.raw(String.format(
|
||||
"Profil MMORPG — %s | Niveau %d | XP %d",
|
||||
profile.getDisplayName(),
|
||||
profile.getLevel(),
|
||||
profile.getExperience())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.disklexar.mmorpg.command;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerDefinition;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerService;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* {@code /mmorpg power <recevoir|retirer|info>}
|
||||
*/
|
||||
public final class PowerCommand extends AbstractCommandCollection {
|
||||
|
||||
public PowerCommand(@Nonnull MmorpgPlugin plugin) {
|
||||
super("power", "Gérer vos pouvoirs passifs");
|
||||
addSubCommand(new Grant(plugin));
|
||||
addSubCommand(new Revoke(plugin));
|
||||
addSubCommand(new Info(plugin));
|
||||
}
|
||||
|
||||
private static final class Grant extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<String> powerArg;
|
||||
|
||||
Grant(@Nonnull MmorpgPlugin plugin) {
|
||||
super("recevoir", "Recevoir un pouvoir");
|
||||
this.plugin = plugin;
|
||||
this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
PowerService powerService = CommandSupport.service(plugin, PowerService.class);
|
||||
if (profile == null || powerService == null) {
|
||||
context.sendMessage(Message.raw("Profil indisponible."));
|
||||
return;
|
||||
}
|
||||
String id = context.get(powerArg);
|
||||
PowerDefinition power = PowerCatalog.byId(id);
|
||||
if (power == null) {
|
||||
context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList()));
|
||||
return;
|
||||
}
|
||||
context.sendMessage(Message.raw(powerService.grant(profile, id)
|
||||
? "Pouvoir reçu : " + power.displayName()
|
||||
: "Vous possédez déjà " + power.displayName() + "."));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Revoke extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
private final RequiredArg<String> powerArg;
|
||||
|
||||
Revoke(@Nonnull MmorpgPlugin plugin) {
|
||||
super("retirer", "Retirer un pouvoir");
|
||||
this.plugin = plugin;
|
||||
this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
PowerService powerService = CommandSupport.service(plugin, PowerService.class);
|
||||
if (profile == null || powerService == null) {
|
||||
context.sendMessage(Message.raw("Profil indisponible."));
|
||||
return;
|
||||
}
|
||||
String id = context.get(powerArg);
|
||||
PowerDefinition power = PowerCatalog.byId(id);
|
||||
if (power == null) {
|
||||
context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList()));
|
||||
return;
|
||||
}
|
||||
context.sendMessage(Message.raw(powerService.remove(profile, id)
|
||||
? "Pouvoir retiré : " + power.displayName()
|
||||
: "Vous ne possédez pas " + power.displayName() + "."));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Info extends AbstractPlayerCommand {
|
||||
private final MmorpgPlugin plugin;
|
||||
|
||||
Info(@Nonnull MmorpgPlugin plugin) {
|
||||
super("info", "Informations sur les pouvoirs");
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
|
||||
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
|
||||
StringBuilder builder = new StringBuilder("=== Pouvoirs (passifs, aucun par défaut) ===");
|
||||
for (PowerDefinition power : PowerCatalog.all()) {
|
||||
boolean owned = profile != null && profile.getPowers().contains(power.id());
|
||||
builder.append("\n- ").append(power.displayName())
|
||||
.append(" (").append(power.id()).append(") ")
|
||||
.append(owned ? "[possédé]" : "")
|
||||
.append("\n ").append(power.description());
|
||||
}
|
||||
context.sendMessage(Message.raw(builder.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String powerList() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (PowerDefinition power : PowerCatalog.all()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(", ");
|
||||
}
|
||||
builder.append(power.id());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.disklexar.mmorpg.core.config;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Plugin configuration loaded from {@code config.json} in the plugin data directory.
|
||||
*/
|
||||
public final class MmorpgConfig {
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private boolean debug;
|
||||
@SerializedName("DefaultLevel")
|
||||
private int defaultLevel = 1;
|
||||
@SerializedName("MaxPlayers")
|
||||
private int maxPlayers = 500;
|
||||
@SerializedName("BaseXpPerLevel")
|
||||
private int baseXpPerLevel = 100;
|
||||
@SerializedName("KillExperience")
|
||||
private int killExperience = 10;
|
||||
private DatabaseConfig database = new DatabaseConfig();
|
||||
private FeaturesConfig features = new FeaturesConfig();
|
||||
|
||||
public static MmorpgConfig load(Path dataDirectory) throws IOException {
|
||||
Files.createDirectories(dataDirectory);
|
||||
Path configFile = dataDirectory.resolve("config.json");
|
||||
|
||||
if (!Files.exists(configFile)) {
|
||||
try (InputStream defaults = MmorpgConfig.class.getResourceAsStream("/config.json")) {
|
||||
if (defaults == null) {
|
||||
throw new IOException("Default config.json not found in plugin resources");
|
||||
}
|
||||
Files.copy(defaults, configFile);
|
||||
}
|
||||
}
|
||||
|
||||
try (InputStreamReader reader = new InputStreamReader(
|
||||
Files.newInputStream(configFile), StandardCharsets.UTF_8)) {
|
||||
MmorpgConfig config = GSON.fromJson(reader, MmorpgConfig.class);
|
||||
if (config.database == null) {
|
||||
config.database = new DatabaseConfig();
|
||||
}
|
||||
if (config.features == null) {
|
||||
config.features = new FeaturesConfig();
|
||||
}
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isDebug() {
|
||||
return debug;
|
||||
}
|
||||
|
||||
public int getDefaultLevel() {
|
||||
return defaultLevel;
|
||||
}
|
||||
|
||||
public int getMaxPlayers() {
|
||||
return maxPlayers;
|
||||
}
|
||||
|
||||
public int getBaseXpPerLevel() {
|
||||
return baseXpPerLevel;
|
||||
}
|
||||
|
||||
public int getKillExperience() {
|
||||
return killExperience;
|
||||
}
|
||||
|
||||
public DatabaseConfig getDatabase() {
|
||||
return database;
|
||||
}
|
||||
|
||||
public FeaturesConfig getFeatures() {
|
||||
return features;
|
||||
}
|
||||
|
||||
public static final class DatabaseConfig {
|
||||
@SerializedName("FileName")
|
||||
private String fileName = "mmorpg.db";
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class FeaturesConfig {
|
||||
private boolean economy;
|
||||
private boolean quests;
|
||||
private boolean guilds;
|
||||
|
||||
public boolean isEconomyEnabled() {
|
||||
return economy;
|
||||
}
|
||||
|
||||
public boolean isQuestsEnabled() {
|
||||
return quests;
|
||||
}
|
||||
|
||||
public boolean isGuildsEnabled() {
|
||||
return guilds;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.disklexar.mmorpg.core.service;
|
||||
|
||||
/**
|
||||
* Lifecycle contract for MMORPG services.
|
||||
*/
|
||||
public interface Service {
|
||||
|
||||
void start();
|
||||
|
||||
void shutdown();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.disklexar.mmorpg.economy;
|
||||
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Minimal currency operations on a {@link PlayerProfile}. Money is persisted with the profile.
|
||||
*/
|
||||
public final class EconomyService {
|
||||
|
||||
public long getBalance(@Nonnull PlayerProfile profile) {
|
||||
return profile.getMoney();
|
||||
}
|
||||
|
||||
public void deposit(@Nonnull PlayerProfile profile, long amount) {
|
||||
if (amount <= 0) {
|
||||
return;
|
||||
}
|
||||
profile.setMoney(profile.getMoney() + amount);
|
||||
profile.touch();
|
||||
}
|
||||
|
||||
public boolean withdraw(@Nonnull PlayerProfile profile, long amount) {
|
||||
if (amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
if (profile.getMoney() < amount) {
|
||||
return false;
|
||||
}
|
||||
profile.setMoney(profile.getMoney() - amount);
|
||||
profile.touch();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Economy system (currency, shops, auctions).
|
||||
* <p>
|
||||
* Disabled by default via {@code Features.Economy} in config.json.
|
||||
* Planned: wallets, transactions, NPC vendors.
|
||||
*/
|
||||
package com.disklexar.mmorpg.economy;
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.disklexar.mmorpg.events;
|
||||
|
||||
import com.disklexar.mmorpg.combat.ClassAbilityController;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.protocol.ForkedChainId;
|
||||
import com.hypixel.hytale.protocol.InteractionType;
|
||||
import com.hypixel.hytale.protocol.Packet;
|
||||
import com.hypixel.hytale.protocol.packets.interaction.CancelInteractionChain;
|
||||
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChain;
|
||||
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChains;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Intercepts {@link SyncInteractionChains} before the vanilla interaction pipeline runs.
|
||||
* When a player with an MMORPG class presses Ability 1/2/3, the vanilla weapon ability is
|
||||
* cancelled client-side and the plugin casts the class ability instead.
|
||||
*/
|
||||
public final class AbilityPacketFilter implements PlayerPacketFilter {
|
||||
|
||||
private final ClassAbilityController controller;
|
||||
|
||||
public AbilityPacketFilter(@Nonnull ClassAbilityController controller) {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean test(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) {
|
||||
if (!(packet instanceof SyncInteractionChains syncPacket)
|
||||
|| syncPacket.updates == null
|
||||
|| syncPacket.updates.length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<SyncInteractionChain> keep = new ArrayList<>(syncPacket.updates.length);
|
||||
boolean blockedAny = false;
|
||||
|
||||
for (SyncInteractionChain chain : syncPacket.updates) {
|
||||
int slot = ClassAbilityController.slotFor(chain.interactionType);
|
||||
if (slot == 0 || !chain.initial || !controller.shouldSuppressVanilla(playerRef, slot)) {
|
||||
keep.add(chain);
|
||||
continue;
|
||||
}
|
||||
|
||||
blockedAny = true;
|
||||
cancelAndCast(playerRef, chain, slot);
|
||||
}
|
||||
|
||||
if (!blockedAny) {
|
||||
return false;
|
||||
}
|
||||
if (keep.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
syncPacket.updates = keep.toArray(new SyncInteractionChain[0]);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void cancelAndCast(
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull SyncInteractionChain chain,
|
||||
int slot) {
|
||||
ForkedChainId forkedId = chain.forkedId;
|
||||
playerRef.getPacketHandler().writeNoCache(new CancelInteractionChain(chain.chainId, forkedId));
|
||||
|
||||
Ref<EntityStore> entityRef = playerRef.getReference();
|
||||
if (entityRef == null || !entityRef.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Store<EntityStore> store = entityRef.getStore();
|
||||
Player player = store.getComponent(entityRef, Player.getComponentType());
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
World world = player.getWorld();
|
||||
world.execute(() -> controller.tryCast(playerRef, world, slot, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.disklexar.mmorpg.events;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.bootstrap.Bootstrap;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayer;
|
||||
import com.disklexar.mmorpg.combat.MmorpgScheduler;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassService;
|
||||
import com.disklexar.mmorpg.ui.AbilityBarService;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
|
||||
import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Handles player join and leave for MMORPG session management.
|
||||
*/
|
||||
public final class PlayerConnectionHandler {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
private final HytaleLogger logger;
|
||||
|
||||
public PlayerConnectionHandler(@Nonnull MmorpgPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
this.logger = plugin.getLogger();
|
||||
}
|
||||
|
||||
public void onPlayerReady(@Nonnull PlayerReadyEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Ref<EntityStore> entityRef = event.getPlayerRef();
|
||||
|
||||
Bootstrap bootstrap = plugin.getBootstrap();
|
||||
if (bootstrap == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Store<EntityStore> store = player.getWorld().getEntityStore().getStore();
|
||||
PlayerRef universePlayerRef = store.getComponent(entityRef, PlayerRef.getComponentType());
|
||||
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
|
||||
|
||||
try {
|
||||
UUID uuid = resolveUuid(store, entityRef, player);
|
||||
String displayName = resolveDisplayName(store, entityRef, player);
|
||||
PlayerProfile profile = sessions.loadOrCreate(uuid, displayName);
|
||||
|
||||
if (universePlayerRef != null) {
|
||||
OnlinePlayerRegistry onlinePlayers =
|
||||
bootstrap.getRegistry().require(OnlinePlayerRegistry.class);
|
||||
OnlinePlayer online = new OnlinePlayer(uuid, universePlayerRef, entityRef, player.getWorld());
|
||||
onlinePlayers.add(online);
|
||||
|
||||
ClassService classService = bootstrap.getRegistry().require(ClassService.class);
|
||||
AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class);
|
||||
MmorpgScheduler scheduler = bootstrap.getRegistry().require(MmorpgScheduler.class);
|
||||
|
||||
universePlayerRef.sendMessage(Message.raw(String.format(
|
||||
"Bienvenue sur le serveur MMORPG — Niveau %d | Race: %s",
|
||||
profile.getLevel(),
|
||||
com.disklexar.mmorpg.rpg.race.RaceCatalog.byId(profile.getRaceId()).displayName())));
|
||||
universePlayerRef.sendMessage(Message.raw(
|
||||
"Commandes : /mmorpg menu (profil) | /mmorpg class info"));
|
||||
|
||||
if (classService.hasClass(profile)) {
|
||||
scheduler.runLater(() -> abilityBar.sync(uuid, profile), 5_000L);
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.SEVERE).log(
|
||||
"Failed to load profile for %s: %s",
|
||||
resolveDisplayName(store, entityRef, player),
|
||||
e.getMessage());
|
||||
if (universePlayerRef != null) {
|
||||
universePlayerRef.sendMessage(Message.raw(
|
||||
"Erreur lors du chargement de votre profil MMORPG."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void onPlayerDisconnect(@Nonnull PlayerDisconnectEvent event) {
|
||||
Bootstrap bootstrap = plugin.getBootstrap();
|
||||
if (bootstrap == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerRef playerRef = event.getPlayerRef();
|
||||
OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class);
|
||||
onlinePlayers.remove(playerRef.getUuid());
|
||||
bootstrap.getRegistry().require(AbilityBarService.class).dismiss(playerRef.getUuid());
|
||||
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
|
||||
sessions.unload(playerRef.getUuid());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private UUID resolveUuid(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull Player player) {
|
||||
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
if (component != null) {
|
||||
return component.getUuid();
|
||||
}
|
||||
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
return playerRef.getUuid();
|
||||
}
|
||||
throw new IllegalStateException("UUID not found for player: " + resolveDisplayName(store, ref, player));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private String resolveDisplayName(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull Player player) {
|
||||
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
return playerRef.getUsername();
|
||||
}
|
||||
return "Joueur";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.disklexar.mmorpg.interaction;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.combat.ClassAbilityController;
|
||||
import com.hypixel.hytale.codec.Codec;
|
||||
import com.hypixel.hytale.codec.KeyedCodec;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.protocol.InteractionState;
|
||||
import com.hypixel.hytale.protocol.InteractionType;
|
||||
import com.hypixel.hytale.server.core.entity.InteractionContext;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Native interaction ({@code mmorpg_cast_ability}) wired from item asset overrides.
|
||||
* Client and server stay in sync when weapon JSON maps Ability1/2/3 to this type.
|
||||
*/
|
||||
public final class MmorpgCastAbilityInteraction extends SimpleInstantInteraction {
|
||||
|
||||
public static final String ID = "mmorpg_cast_ability";
|
||||
|
||||
public static final BuilderCodec<MmorpgCastAbilityInteraction> CODEC = BuilderCodec.builder(
|
||||
MmorpgCastAbilityInteraction.class,
|
||||
MmorpgCastAbilityInteraction::new,
|
||||
SimpleInstantInteraction.CODEC)
|
||||
.append(new KeyedCodec<>("Slot", Codec.INTEGER),
|
||||
(interaction, slot) -> interaction.slot = slot,
|
||||
interaction -> interaction.slot)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private int slot = 1;
|
||||
|
||||
public MmorpgCastAbilityInteraction() {
|
||||
}
|
||||
|
||||
public MmorpgCastAbilityInteraction(@Nonnull String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void firstRun(
|
||||
@Nonnull InteractionType interactionType,
|
||||
@Nonnull InteractionContext interactionContext,
|
||||
@Nonnull CooldownHandler cooldownHandler) {
|
||||
int abilitySlot = slot;
|
||||
if (abilitySlot == 0) {
|
||||
abilitySlot = ClassAbilityController.slotFor(interactionType);
|
||||
}
|
||||
if (abilitySlot == 0) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
return;
|
||||
}
|
||||
|
||||
Ref<EntityStore> entityRef = interactionContext.getEntity();
|
||||
if (entityRef == null || !entityRef.isValid()) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
return;
|
||||
}
|
||||
|
||||
Store<EntityStore> store = entityRef.getStore();
|
||||
PlayerRef playerRef = store.getComponent(entityRef, PlayerRef.getComponentType());
|
||||
Player player = store.getComponent(entityRef, Player.getComponentType());
|
||||
if (playerRef == null || player == null) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
return;
|
||||
}
|
||||
|
||||
MmorpgPlugin plugin = MmorpgPlugin.get();
|
||||
if (plugin == null) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
return;
|
||||
}
|
||||
|
||||
World world = player.getWorld();
|
||||
Ref<EntityStore> target = interactionContext.getTargetEntity();
|
||||
ClassAbilityController controller = plugin.getClassAbilityController();
|
||||
if (!controller.tryCast(playerRef, world, abilitySlot, target)) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.disklexar.mmorpg.persistence;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.core.config.MmorpgConfig;
|
||||
import com.disklexar.mmorpg.core.service.Service;
|
||||
import com.disklexar.mmorpg.persistence.schema.SchemaInitializer;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Manages the SQLite database connection for the MMORPG plugin.
|
||||
*/
|
||||
public final class DatabaseManager implements Service {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
private final MmorpgConfig config;
|
||||
private final HytaleLogger logger;
|
||||
private Connection connection;
|
||||
|
||||
public DatabaseManager(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull MmorpgConfig config) {
|
||||
this.plugin = plugin;
|
||||
this.config = config;
|
||||
this.logger = plugin.getLogger();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
try {
|
||||
Path dbPath = plugin.getDataDirectory().resolve(config.getDatabase().getFileName());
|
||||
String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath();
|
||||
|
||||
connection = DriverManager.getConnection(jdbcUrl);
|
||||
connection.setAutoCommit(true);
|
||||
|
||||
SchemaInitializer.apply(connection);
|
||||
|
||||
logger.at(Level.INFO).log("SQLite database ready at %s", dbPath);
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalStateException("Failed to initialize SQLite database", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
logger.at(Level.INFO).log("SQLite connection closed");
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log("Error closing SQLite connection: %s", e.getMessage());
|
||||
} finally {
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Connection getConnection() {
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("Database is not initialized");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.disklexar.mmorpg.persistence.repository;
|
||||
|
||||
import com.disklexar.mmorpg.persistence.DatabaseManager;
|
||||
import com.disklexar.mmorpg.rpg.group.Group;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* SQLite persistence for {@link Group} parties and their members.
|
||||
*/
|
||||
public final class GroupRepository {
|
||||
|
||||
private final DatabaseManager databaseManager;
|
||||
|
||||
public GroupRepository(@Nonnull DatabaseManager databaseManager) {
|
||||
this.databaseManager = databaseManager;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public List<Group> loadAll() throws SQLException {
|
||||
List<Group> groups = new ArrayList<>();
|
||||
try (PreparedStatement statement = connection().prepareStatement(
|
||||
"SELECT id, owner_uuid, created_at FROM groups");
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
Group group = new Group(
|
||||
UUID.fromString(resultSet.getString("id")),
|
||||
UUID.fromString(resultSet.getString("owner_uuid")),
|
||||
resultSet.getLong("created_at"));
|
||||
groups.add(group);
|
||||
}
|
||||
}
|
||||
for (Group group : groups) {
|
||||
try (PreparedStatement statement = connection().prepareStatement(
|
||||
"SELECT player_uuid FROM group_members WHERE group_id = ? ORDER BY joined_at")) {
|
||||
statement.setString(1, group.getId().toString());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
group.getMembers().add(UUID.fromString(resultSet.getString("player_uuid")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
public void insertGroup(@Nonnull Group group) throws SQLException {
|
||||
try (PreparedStatement statement = connection().prepareStatement(
|
||||
"INSERT OR REPLACE INTO groups (id, owner_uuid, created_at) VALUES (?, ?, ?)")) {
|
||||
statement.setString(1, group.getId().toString());
|
||||
statement.setString(2, group.getOwner().toString());
|
||||
statement.setLong(3, group.getCreatedAt());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteGroup(@Nonnull UUID groupId) throws SQLException {
|
||||
try (PreparedStatement members = connection().prepareStatement(
|
||||
"DELETE FROM group_members WHERE group_id = ?")) {
|
||||
members.setString(1, groupId.toString());
|
||||
members.executeUpdate();
|
||||
}
|
||||
try (PreparedStatement statement = connection().prepareStatement(
|
||||
"DELETE FROM groups WHERE id = ?")) {
|
||||
statement.setString(1, groupId.toString());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void addMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException {
|
||||
try (PreparedStatement statement = connection().prepareStatement(
|
||||
"INSERT OR REPLACE INTO group_members (group_id, player_uuid, joined_at) VALUES (?, ?, ?)")) {
|
||||
statement.setString(1, groupId.toString());
|
||||
statement.setString(2, player.toString());
|
||||
statement.setLong(3, System.currentTimeMillis());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void removeMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException {
|
||||
try (PreparedStatement statement = connection().prepareStatement(
|
||||
"DELETE FROM group_members WHERE group_id = ? AND player_uuid = ?")) {
|
||||
statement.setString(1, groupId.toString());
|
||||
statement.setString(2, player.toString());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Connection connection() {
|
||||
return databaseManager.getConnection();
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.disklexar.mmorpg.persistence.repository;
|
||||
|
||||
import com.disklexar.mmorpg.persistence.DatabaseManager;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.lang.reflect.Type;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* SQLite persistence for {@link PlayerProfile}.
|
||||
*/
|
||||
public final class PlayerProfileRepository {
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final Type STRING_LIST = new TypeToken<List<String>>() {
|
||||
}.getType();
|
||||
|
||||
private final DatabaseManager databaseManager;
|
||||
|
||||
public PlayerProfileRepository(@Nonnull DatabaseManager databaseManager) {
|
||||
this.databaseManager = databaseManager;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Optional<PlayerProfile> findByUuid(@Nonnull UUID uuid) throws SQLException {
|
||||
String sql = """
|
||||
SELECT uuid, display_name, level, experience, class_id, powers, jobs,
|
||||
guild_id, group_id, is_connected, total_time_play, last_date_connected,
|
||||
date_creation, money, race_id, updated_at
|
||||
FROM player_profiles
|
||||
WHERE uuid = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement statement = connection().prepareStatement(sql)) {
|
||||
statement.setString(1, uuid.toString());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(mapRow(resultSet));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public PlayerProfile save(@Nonnull PlayerProfile profile) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO player_profiles (
|
||||
uuid, display_name, level, experience, class_id, powers, jobs,
|
||||
guild_id, group_id, is_connected, total_time_play, last_date_connected,
|
||||
date_creation, money, race_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
level = excluded.level,
|
||||
experience = excluded.experience,
|
||||
class_id = excluded.class_id,
|
||||
powers = excluded.powers,
|
||||
jobs = excluded.jobs,
|
||||
guild_id = excluded.guild_id,
|
||||
group_id = excluded.group_id,
|
||||
is_connected = excluded.is_connected,
|
||||
total_time_play = excluded.total_time_play,
|
||||
last_date_connected = excluded.last_date_connected,
|
||||
money = excluded.money,
|
||||
race_id = excluded.race_id,
|
||||
updated_at = excluded.updated_at
|
||||
""";
|
||||
|
||||
try (PreparedStatement statement = connection().prepareStatement(sql)) {
|
||||
statement.setString(1, profile.getUuid().toString());
|
||||
statement.setString(2, profile.getDisplayName());
|
||||
statement.setInt(3, profile.getLevel());
|
||||
statement.setLong(4, profile.getExperience());
|
||||
statement.setString(5, profile.getClassId());
|
||||
statement.setString(6, GSON.toJson(profile.getPowers()));
|
||||
statement.setString(7, GSON.toJson(profile.getJobs()));
|
||||
statement.setString(8, profile.getGuildId());
|
||||
statement.setString(9, profile.getGroupId());
|
||||
statement.setInt(10, profile.isConnected() ? 1 : 0);
|
||||
statement.setLong(11, profile.getTotalTimePlay());
|
||||
statement.setLong(12, profile.getLastDateConnected());
|
||||
statement.setLong(13, profile.getDateCreation());
|
||||
statement.setLong(14, profile.getMoney());
|
||||
statement.setString(15, profile.getRaceId());
|
||||
// created_at mirrors date_creation to keep the original 001 column populated.
|
||||
statement.setLong(16, profile.getDateCreation());
|
||||
statement.setLong(17, profile.getUpdatedAt());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** Resets {@code is_connected} for every profile (called on boot to recover from crashes). */
|
||||
public void clearConnectedFlags() throws SQLException {
|
||||
try (PreparedStatement statement = connection().prepareStatement(
|
||||
"UPDATE player_profiles SET is_connected = 0 WHERE is_connected <> 0")) {
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private PlayerProfile mapRow(@Nonnull ResultSet resultSet) throws SQLException {
|
||||
PlayerProfile profile = new PlayerProfile(
|
||||
UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getString("display_name"),
|
||||
resultSet.getLong("date_creation"));
|
||||
profile.setLevel(resultSet.getInt("level"));
|
||||
profile.setExperience(resultSet.getLong("experience"));
|
||||
profile.setClassId(resultSet.getString("class_id"));
|
||||
profile.getPowers().addAll(parseList(resultSet.getString("powers")));
|
||||
profile.getJobs().addAll(parseList(resultSet.getString("jobs")));
|
||||
profile.setGuildId(resultSet.getString("guild_id"));
|
||||
profile.setGroupId(resultSet.getString("group_id"));
|
||||
profile.setConnected(resultSet.getInt("is_connected") != 0);
|
||||
profile.setTotalTimePlay(resultSet.getLong("total_time_play"));
|
||||
profile.setLastDateConnected(resultSet.getLong("last_date_connected"));
|
||||
profile.setMoney(resultSet.getLong("money"));
|
||||
String raceId = resultSet.getString("race_id");
|
||||
profile.setRaceId(raceId != null ? raceId : "human");
|
||||
profile.setUpdatedAt(resultSet.getLong("updated_at"));
|
||||
return profile;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private List<String> parseList(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> parsed = GSON.fromJson(json, STRING_LIST);
|
||||
return parsed != null ? parsed : List.of();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Connection connection() {
|
||||
return databaseManager.getConnection();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.disklexar.mmorpg.persistence.schema;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Applies ordered SQL migrations from classpath resources.
|
||||
* <p>
|
||||
* Each migration is applied at most once. A {@code schema_migrations} table records which
|
||||
* migrations already ran, so new files can be added without re-running previous ones (which
|
||||
* matters for non-idempotent statements such as {@code ALTER TABLE ... ADD COLUMN}).
|
||||
*/
|
||||
public final class SchemaInitializer {
|
||||
|
||||
private static final List<String> MIGRATIONS = List.of(
|
||||
"001_init.sql",
|
||||
"002_rpg.sql");
|
||||
|
||||
private SchemaInitializer() {
|
||||
}
|
||||
|
||||
public static void apply(@Nonnull Connection connection) throws SQLException {
|
||||
ensureMigrationsTable(connection);
|
||||
for (String migration : MIGRATIONS) {
|
||||
if (isApplied(connection, migration)) {
|
||||
continue;
|
||||
}
|
||||
runMigration(connection, migration);
|
||||
markApplied(connection, migration);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureMigrationsTable(@Nonnull Connection connection) throws SQLException {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT 1 FROM schema_migrations WHERE id = ?")) {
|
||||
statement.setString(1, migration);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void markApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)")) {
|
||||
statement.setString(1, migration);
|
||||
statement.setLong(2, System.currentTimeMillis());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static void runMigration(@Nonnull Connection connection, @Nonnull String migration) throws SQLException {
|
||||
String sql = loadMigration("/db/migrations/" + migration);
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
for (String chunk : sql.split(";")) {
|
||||
String trimmed = stripComments(chunk).trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
statement.execute(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String stripComments(@Nonnull String sql) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String line : sql.split("\n")) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
builder.append(line).append('\n');
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String loadMigration(@Nonnull String resourcePath) throws SQLException {
|
||||
try (InputStream input = SchemaInitializer.class.getResourceAsStream(resourcePath)) {
|
||||
if (input == null) {
|
||||
throw new SQLException("Migration resource not found: " + resourcePath);
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(input, StandardCharsets.UTF_8))) {
|
||||
return reader.lines().collect(Collectors.joining("\n"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new SQLException("Failed to read migration: " + resourcePath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.disklexar.mmorpg.player;
|
||||
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A snapshot of the runtime handles for an online player, used to apply live effects.
|
||||
*/
|
||||
public record OnlinePlayer(
|
||||
@Nonnull UUID uuid,
|
||||
@Nonnull PlayerRef playerRef,
|
||||
@Nonnull Ref<EntityStore> entityRef,
|
||||
@Nonnull World world) {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.disklexar.mmorpg.player;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Tracks runtime handles ({@link OnlinePlayer}) for connected players so services can apply
|
||||
* live effects (movement buffs, abilities) by UUID.
|
||||
*/
|
||||
public final class OnlinePlayerRegistry {
|
||||
|
||||
private final Map<UUID, OnlinePlayer> online = new ConcurrentHashMap<>();
|
||||
|
||||
public void add(@Nonnull OnlinePlayer player) {
|
||||
online.put(player.uuid(), player);
|
||||
}
|
||||
|
||||
public void remove(@Nonnull UUID uuid) {
|
||||
online.remove(uuid);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public OnlinePlayer get(@Nonnull UUID uuid) {
|
||||
return online.get(uuid);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Collection<OnlinePlayer> all() {
|
||||
return online.values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.disklexar.mmorpg.player;
|
||||
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
|
||||
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
|
||||
import com.disklexar.mmorpg.rpg.job.JobCatalog;
|
||||
import com.disklexar.mmorpg.rpg.job.JobDefinition;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
|
||||
import com.disklexar.mmorpg.rpg.power.PowerDefinition;
|
||||
import com.disklexar.mmorpg.rpg.race.RaceCatalog;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* Builds a human-readable summary of every MMORPG attribute of a player. Shared by the chat
|
||||
* {@code /mmorpg info} command and the custom UI page so both stay consistent.
|
||||
*/
|
||||
public final class PlayerInfoView {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT =
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault());
|
||||
|
||||
public record Entry(@Nonnull String label, @Nonnull String value) {
|
||||
}
|
||||
|
||||
private PlayerInfoView() {
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static List<Entry> entries(@Nonnull PlayerProfile profile) {
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
entries.add(new Entry("Nom", profile.getDisplayName()));
|
||||
entries.add(new Entry("Niveau", String.valueOf(profile.getLevel())));
|
||||
entries.add(new Entry("Expérience", String.valueOf(profile.getExperience())));
|
||||
entries.add(new Entry("Argent", profile.getMoney() + " money"));
|
||||
entries.add(new Entry("Race", RaceCatalog.byId(profile.getRaceId()).displayName()));
|
||||
entries.add(new Entry("Classe", className(profile)));
|
||||
entries.add(new Entry("Pouvoirs", powerNames(profile)));
|
||||
entries.add(new Entry("Métiers", jobNames(profile)));
|
||||
entries.add(new Entry("Groupe", profile.getGroupId() != null ? "Oui" : "Aucun"));
|
||||
entries.add(new Entry("Guilde", profile.getGuildId() != null ? profile.getGuildId() : "Aucune"));
|
||||
entries.add(new Entry("Connecté", profile.isConnected() ? "Oui" : "Non"));
|
||||
entries.add(new Entry("Temps de jeu", formatDuration(profile.getTotalTimePlay())));
|
||||
entries.add(new Entry("Dernière connexion", formatDate(profile.getLastDateConnected())));
|
||||
entries.add(new Entry("Création", formatDate(profile.getDateCreation())));
|
||||
return entries;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static String asText(@Nonnull PlayerProfile profile) {
|
||||
StringBuilder builder = new StringBuilder("=== Profil MMORPG ===");
|
||||
for (Entry entry : entries(profile)) {
|
||||
builder.append('\n').append(entry.label()).append(" : ").append(entry.value());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String className(@Nonnull PlayerProfile profile) {
|
||||
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
|
||||
return playerClass != null ? playerClass.displayName() : "Aucune";
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String powerNames(@Nonnull PlayerProfile profile) {
|
||||
if (profile.getPowers().isEmpty()) {
|
||||
return "Aucun";
|
||||
}
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
for (String id : profile.getPowers()) {
|
||||
PowerDefinition power = PowerCatalog.byId(id);
|
||||
joiner.add(power != null ? power.displayName() : id);
|
||||
}
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String jobNames(@Nonnull PlayerProfile profile) {
|
||||
if (profile.getJobs().isEmpty()) {
|
||||
return "Aucun";
|
||||
}
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
for (String id : profile.getJobs()) {
|
||||
JobDefinition job = JobCatalog.byId(id);
|
||||
joiner.add(job != null ? job.displayName() : id);
|
||||
}
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String formatDuration(long millis) {
|
||||
if (millis <= 0) {
|
||||
return "0 min";
|
||||
}
|
||||
Duration duration = Duration.ofMillis(millis);
|
||||
long hours = duration.toHours();
|
||||
long minutes = duration.toMinutesPart();
|
||||
return hours > 0 ? hours + "h " + minutes + "min" : minutes + "min";
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String formatDate(long epochMillis) {
|
||||
if (epochMillis <= 0) {
|
||||
return "—";
|
||||
}
|
||||
return DATE_FORMAT.format(Instant.ofEpochMilli(epochMillis));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.disklexar.mmorpg.player;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.core.config.MmorpgConfig;
|
||||
import com.disklexar.mmorpg.core.service.Service;
|
||||
import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.race.RaceCatalog;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Tracks online player sessions and synchronizes profiles with SQLite.
|
||||
*/
|
||||
public final class PlayerSessionService implements Service {
|
||||
|
||||
private final MmorpgPlugin plugin;
|
||||
private final MmorpgConfig config;
|
||||
private final PlayerProfileRepository repository;
|
||||
private final HytaleLogger logger;
|
||||
private final Map<UUID, PlayerProfile> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> connectedAt = new ConcurrentHashMap<>();
|
||||
|
||||
public PlayerSessionService(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull MmorpgConfig config,
|
||||
@Nonnull PlayerProfileRepository repository) {
|
||||
this.plugin = plugin;
|
||||
this.config = config;
|
||||
this.repository = repository;
|
||||
this.logger = plugin.getLogger();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
try {
|
||||
repository.clearConnectedFlags();
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log("Failed to reset connection flags: %s", e.getMessage());
|
||||
}
|
||||
logger.at(Level.INFO).log("Player session service started");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
for (PlayerProfile profile : sessions.values()) {
|
||||
accumulatePlaytime(profile.getUuid(), profile);
|
||||
profile.setConnected(false);
|
||||
saveQuietly(profile);
|
||||
}
|
||||
sessions.clear();
|
||||
connectedAt.clear();
|
||||
logger.at(Level.INFO).log("Player session service stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads (or creates) a profile and marks the player connected.
|
||||
*/
|
||||
@Nonnull
|
||||
public PlayerProfile loadOrCreate(@Nonnull UUID uuid, @Nonnull String displayName) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
Optional<PlayerProfile> existing = repository.findByUuid(uuid);
|
||||
PlayerProfile profile;
|
||||
if (existing.isPresent()) {
|
||||
profile = existing.get();
|
||||
profile.setDisplayName(displayName);
|
||||
} else {
|
||||
profile = new PlayerProfile(uuid, displayName, now);
|
||||
profile.setLevel(config.getDefaultLevel());
|
||||
profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID);
|
||||
logger.at(Level.INFO).log("Created new MMORPG profile for %s", displayName);
|
||||
}
|
||||
|
||||
profile.setConnected(true);
|
||||
profile.setLastDateConnected(now);
|
||||
profile.touch();
|
||||
sessions.put(uuid, profile);
|
||||
connectedAt.put(uuid, now);
|
||||
saveQuietly(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public PlayerProfile getSession(@Nonnull UUID uuid) {
|
||||
return sessions.get(uuid);
|
||||
}
|
||||
|
||||
public void unload(@Nonnull UUID uuid) {
|
||||
PlayerProfile profile = sessions.remove(uuid);
|
||||
if (profile == null) {
|
||||
return;
|
||||
}
|
||||
accumulatePlaytime(uuid, profile);
|
||||
profile.setConnected(false);
|
||||
profile.touch();
|
||||
saveQuietly(profile);
|
||||
}
|
||||
|
||||
private void accumulatePlaytime(@Nonnull UUID uuid, @Nonnull PlayerProfile profile) {
|
||||
Long start = connectedAt.remove(uuid);
|
||||
if (start != null) {
|
||||
profile.addTimePlay(System.currentTimeMillis() - start);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveQuietly(@Nonnull PlayerProfile profile) {
|
||||
try {
|
||||
repository.save(profile);
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log(
|
||||
"Failed to save profile for %s: %s", profile.getUuid(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.disklexar.mmorpg.player.model;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Persistent MMORPG player profile.
|
||||
* <p>
|
||||
* Mutable so gameplay services can update progression in-session; the
|
||||
* {@code updatedAt} timestamp is refreshed by {@link #touch()} on every change and the whole
|
||||
* profile is flushed to SQLite on unload / shutdown.
|
||||
*/
|
||||
public final class PlayerProfile {
|
||||
|
||||
private final UUID uuid;
|
||||
private String displayName;
|
||||
private int level;
|
||||
private long experience;
|
||||
|
||||
@Nullable
|
||||
private String classId;
|
||||
private final Set<String> powers = new LinkedHashSet<>();
|
||||
private final Set<String> jobs = new LinkedHashSet<>();
|
||||
@Nullable
|
||||
private String guildId;
|
||||
@Nullable
|
||||
private String groupId;
|
||||
private boolean connected;
|
||||
private long totalTimePlay;
|
||||
private long lastDateConnected;
|
||||
private long money;
|
||||
private String raceId = "human";
|
||||
|
||||
private final long dateCreation;
|
||||
private long updatedAt;
|
||||
|
||||
public PlayerProfile(@Nonnull UUID uuid, @Nonnull String displayName, long dateCreation) {
|
||||
this.uuid = uuid;
|
||||
this.displayName = displayName;
|
||||
this.dateCreation = dateCreation;
|
||||
this.updatedAt = dateCreation;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(@Nonnull String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public int getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
public void setLevel(int level) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public long getExperience() {
|
||||
return experience;
|
||||
}
|
||||
|
||||
public void setExperience(long experience) {
|
||||
this.experience = experience;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getClassId() {
|
||||
return classId;
|
||||
}
|
||||
|
||||
public void setClassId(@Nullable String classId) {
|
||||
this.classId = classId;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> getPowers() {
|
||||
return powers;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> getJobs() {
|
||||
return jobs;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getGuildId() {
|
||||
return guildId;
|
||||
}
|
||||
|
||||
public void setGuildId(@Nullable String guildId) {
|
||||
this.guildId = guildId;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public void setGroupId(@Nullable String groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
public void setConnected(boolean connected) {
|
||||
this.connected = connected;
|
||||
}
|
||||
|
||||
public long getTotalTimePlay() {
|
||||
return totalTimePlay;
|
||||
}
|
||||
|
||||
public void setTotalTimePlay(long totalTimePlay) {
|
||||
this.totalTimePlay = totalTimePlay;
|
||||
}
|
||||
|
||||
public void addTimePlay(long deltaMillis) {
|
||||
this.totalTimePlay += Math.max(0L, deltaMillis);
|
||||
}
|
||||
|
||||
public long getLastDateConnected() {
|
||||
return lastDateConnected;
|
||||
}
|
||||
|
||||
public void setLastDateConnected(long lastDateConnected) {
|
||||
this.lastDateConnected = lastDateConnected;
|
||||
}
|
||||
|
||||
public long getMoney() {
|
||||
return money;
|
||||
}
|
||||
|
||||
public void setMoney(long money) {
|
||||
this.money = Math.max(0L, money);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public String getRaceId() {
|
||||
return raceId;
|
||||
}
|
||||
|
||||
public void setRaceId(@Nonnull String raceId) {
|
||||
this.raceId = raceId;
|
||||
}
|
||||
|
||||
public long getDateCreation() {
|
||||
return dateCreation;
|
||||
}
|
||||
|
||||
public long getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(long updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
/** Refreshes {@link #updatedAt} to now; call after mutating any field. */
|
||||
public void touch() {
|
||||
this.updatedAt = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.disklexar.mmorpg.progression;
|
||||
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.rpg.race.RaceCatalog;
|
||||
import com.disklexar.mmorpg.rpg.race.RaceDefinition;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Experience and leveling. Applies the player's race XP multiplier (Humain x2).
|
||||
*/
|
||||
public final class ProgressionService {
|
||||
|
||||
private final int baseXpPerLevel;
|
||||
|
||||
public ProgressionService(int baseXpPerLevel) {
|
||||
this.baseXpPerLevel = Math.max(1, baseXpPerLevel);
|
||||
}
|
||||
|
||||
/** XP required to advance from {@code level} to {@code level + 1}. */
|
||||
public long xpForLevel(int level) {
|
||||
return (long) baseXpPerLevel * Math.max(1, level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants {@code baseAmount} XP, scaled by the player's race multiplier when requested.
|
||||
*
|
||||
* @return the amount of XP actually added (after multiplier).
|
||||
*/
|
||||
public long grantExperience(@Nonnull PlayerProfile profile, long baseAmount, boolean applyRaceMultiplier) {
|
||||
if (baseAmount <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
long granted = baseAmount;
|
||||
if (applyRaceMultiplier) {
|
||||
RaceDefinition race = RaceCatalog.byId(profile.getRaceId());
|
||||
granted = Math.round(baseAmount * race.xpMultiplier());
|
||||
}
|
||||
profile.setExperience(profile.getExperience() + granted);
|
||||
applyLevelUps(profile);
|
||||
profile.touch();
|
||||
return granted;
|
||||
}
|
||||
|
||||
private void applyLevelUps(@Nonnull PlayerProfile profile) {
|
||||
long needed = xpForLevel(profile.getLevel());
|
||||
while (profile.getExperience() >= needed) {
|
||||
profile.setExperience(profile.getExperience() - needed);
|
||||
profile.setLevel(profile.getLevel() + 1);
|
||||
needed = xpForLevel(profile.getLevel());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Quest system (objectives, rewards, quest chains).
|
||||
* <p>
|
||||
* Disabled by default via {@code Features.Quests} in config.json.
|
||||
* Planned: quest definitions, progress tracking, daily quests.
|
||||
*/
|
||||
package com.disklexar.mmorpg.quest;
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.disklexar.mmorpg.rpg.clazz;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* A single class ability (active skill) with its cooldown.
|
||||
*/
|
||||
public record AbilityDefinition(
|
||||
@Nonnull String id,
|
||||
int slot,
|
||||
@Nonnull String displayName,
|
||||
@Nonnull String description,
|
||||
long cooldownMillis) {
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.disklexar.mmorpg.rpg.clazz;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Registry of all playable classes.
|
||||
*/
|
||||
public final class ClassCatalog {
|
||||
|
||||
public static final String WEAPON_LONGSWORD = "LongSword";
|
||||
public static final String WEAPON_BATTLEAXE = "BattleAxe";
|
||||
public static final String WEAPON_BOW = "Bow";
|
||||
public static final String WEAPON_CROSSBOW = "Crossbow";
|
||||
public static final String WEAPON_STAFF = "Staff";
|
||||
public static final String WEAPON_DAGGERS = "Daggers";
|
||||
|
||||
public static final String ABILITY_DASH = "knight_dash";
|
||||
public static final String ABILITY_SPIN = "knight_spin";
|
||||
public static final String ABILITY_SHOUT = "knight_shout";
|
||||
|
||||
public static final String ABILITY_ARCHER_RAPIDFIRE = "archer_rapidfire";
|
||||
public static final String ABILITY_ARCHER_VOLLEY = "archer_volley";
|
||||
public static final String ABILITY_ARCHER_POWERSHOT = "archer_powershot";
|
||||
|
||||
public static final String ABILITY_MAGE_FIREBALLS = "mage_fireballs";
|
||||
public static final String ABILITY_MAGE_TELEPORT = "mage_teleport";
|
||||
public static final String ABILITY_MAGE_TORNADO = "mage_tornado";
|
||||
|
||||
public static final String ABILITY_ASSASSIN_CAMOUFLAGE = "assassin_camouflage";
|
||||
public static final String ABILITY_ASSASSIN_SHADOWSTEP = "assassin_shadowstep";
|
||||
public static final String ABILITY_ASSASSIN_POISONBLADE = "assassin_poisonblade";
|
||||
|
||||
public static final PlayerClass KNIGHT = new PlayerClass(
|
||||
"knight",
|
||||
"Chevalier",
|
||||
"Combattant lourd au corps-à-corps. Utilise l'Épée longue et la Hache de bataille.",
|
||||
List.of(WEAPON_LONGSWORD, WEAPON_BATTLEAXE),
|
||||
List.of(
|
||||
new AbilityDefinition(ABILITY_DASH, 1, "Charge",
|
||||
"Petit dash en avant qui étourdit 3s tous les ennemis touchés.", 8_000L),
|
||||
new AbilityDefinition(ABILITY_SPIN, 2, "Toupie",
|
||||
"Tournoiement infligeant des dégâts autour de vous pendant 3s.", 12_000L),
|
||||
new AbilityDefinition(ABILITY_SHOUT, 3, "Cri de guerre",
|
||||
"Repousse les entités proches (dégâts bonus si étourdies) et réduit leur résistance de 20% pendant 20s.",
|
||||
15_000L)));
|
||||
|
||||
public static final PlayerClass ARCHER = new PlayerClass(
|
||||
"archer",
|
||||
"Archer",
|
||||
"Tireur à distance. Utilise l'Arc et l'Arbalète.",
|
||||
List.of(WEAPON_BOW, WEAPON_CROSSBOW),
|
||||
List.of(
|
||||
new AbilityDefinition(ABILITY_ARCHER_RAPIDFIRE, 1, "Tir rapide",
|
||||
"Augmente votre cadence de tir (+20% de dégâts) pendant 10s.", 12_000L),
|
||||
new AbilityDefinition(ABILITY_ARCHER_VOLLEY, 2, "Salve",
|
||||
"Bond en arrière puis décoche instantanément 6 flèches devant vous.", 10_000L),
|
||||
new AbilityDefinition(ABILITY_ARCHER_POWERSHOT, 3, "Tir puissant",
|
||||
"Votre prochaine attaque de base inflige +200% de dégâts.", 9_000L)));
|
||||
|
||||
public static final PlayerClass MAGE = new PlayerClass(
|
||||
"mage",
|
||||
"Mage",
|
||||
"Lanceur de sorts à distance. Utilise le Bâton.",
|
||||
List.of(WEAPON_STAFF),
|
||||
List.of(
|
||||
new AbilityDefinition(ABILITY_MAGE_FIREBALLS, 1, "Nova de Givre",
|
||||
"Envoie instantanément 3 boules de feu vers l'avant.", 8_000L),
|
||||
new AbilityDefinition(ABILITY_MAGE_TELEPORT, 2, "Téléportation",
|
||||
"Vous téléporte instantanément de 8 blocs vers l'avant.", 10_000L),
|
||||
new AbilityDefinition(ABILITY_MAGE_TORNADO, 3, "Surcharge Arcane",
|
||||
"Crée une tornade dévastatrice 15 blocs devant vous.", 16_000L)));
|
||||
|
||||
public static final PlayerClass ASSASSIN = new PlayerClass(
|
||||
"assassin",
|
||||
"Assassin",
|
||||
"Tueur furtif au corps-à-corps. Utilise les Dagues.",
|
||||
List.of(WEAPON_DAGGERS),
|
||||
List.of(
|
||||
new AbilityDefinition(ABILITY_ASSASSIN_CAMOUFLAGE, 1, "Camouflage",
|
||||
"Devient totalement invisible pendant 6s.", 14_000L),
|
||||
new AbilityDefinition(ABILITY_ASSASSIN_SHADOWSTEP, 2, "Pas de l'Ombre",
|
||||
"Se téléporte instantanément dans le dos de la cible visée.", 9_000L),
|
||||
new AbilityDefinition(ABILITY_ASSASSIN_POISONBLADE, 3, "Lame Empoisonnée",
|
||||
"Pendant 10s, chaque coup applique un poison (dégâts magiques/seconde pendant 5s, cumulable).",
|
||||
13_000L)));
|
||||
|
||||
private static final Map<String, PlayerClass> BY_ID = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
register(KNIGHT);
|
||||
register(ARCHER);
|
||||
register(MAGE);
|
||||
register(ASSASSIN);
|
||||
}
|
||||
|
||||
private ClassCatalog() {
|
||||
}
|
||||
|
||||
private static void register(@Nonnull PlayerClass playerClass) {
|
||||
BY_ID.put(playerClass.id().toLowerCase(), playerClass);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static PlayerClass byId(@Nullable String id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return BY_ID.get(id.toLowerCase());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static List<PlayerClass> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.disklexar.mmorpg.rpg.clazz;
|
||||
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Selecting / leaving a player class.
|
||||
*/
|
||||
public final class ClassService {
|
||||
|
||||
@Nullable
|
||||
public PlayerClass getClass(@Nonnull PlayerProfile profile) {
|
||||
return ClassCatalog.byId(profile.getClassId());
|
||||
}
|
||||
|
||||
public boolean hasClass(@Nonnull PlayerProfile profile) {
|
||||
return getClass(profile) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a class to the player.
|
||||
*
|
||||
* @return {@code true} if the class exists and was assigned.
|
||||
*/
|
||||
public boolean choose(@Nonnull PlayerProfile profile, @Nonnull String classId) {
|
||||
PlayerClass playerClass = ClassCatalog.byId(classId);
|
||||
if (playerClass == null) {
|
||||
return false;
|
||||
}
|
||||
profile.setClassId(playerClass.id());
|
||||
profile.touch();
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean leave(@Nonnull PlayerProfile profile) {
|
||||
if (profile.getClassId() == null) {
|
||||
return false;
|
||||
}
|
||||
profile.setClassId(null);
|
||||
profile.touch();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.disklexar.mmorpg.rpg.clazz;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Definition of a playable class: its usable weapons and its abilities.
|
||||
*/
|
||||
public record PlayerClass(
|
||||
@Nonnull String id,
|
||||
@Nonnull String displayName,
|
||||
@Nonnull String description,
|
||||
@Nonnull List<String> weapons,
|
||||
@Nonnull List<AbilityDefinition> abilities) {
|
||||
|
||||
public boolean usesWeapon(@Nonnull String weaponId) {
|
||||
for (String weapon : weapons) {
|
||||
if (weapon.equalsIgnoreCase(weaponId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.disklexar.mmorpg.rpg.group;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* A party of players. When a member kills a mob, the other members receive a share of the XP.
|
||||
*/
|
||||
public final class Group {
|
||||
|
||||
private final UUID id;
|
||||
private final UUID owner;
|
||||
private final long createdAt;
|
||||
private final Set<UUID> members = ConcurrentHashMap.newKeySet();
|
||||
private final Set<UUID> pendingInvites = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public Group(@Nonnull UUID id, @Nonnull UUID owner, long createdAt) {
|
||||
this.id = id;
|
||||
this.owner = owner;
|
||||
this.createdAt = createdAt;
|
||||
this.members.add(owner);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public UUID getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public long getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<UUID> getMembers() {
|
||||
return members;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<UUID> getPendingInvites() {
|
||||
return pendingInvites;
|
||||
}
|
||||
|
||||
public boolean isOwner(@Nonnull UUID uuid) {
|
||||
return owner.equals(uuid);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<UUID> membersExcept(@Nonnull UUID uuid) {
|
||||
Set<UUID> others = new LinkedHashSet<>(members);
|
||||
others.remove(uuid);
|
||||
return others;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.disklexar.mmorpg.rpg.group;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.core.service.Service;
|
||||
import com.disklexar.mmorpg.persistence.repository.GroupRepository;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.progression.ProgressionService;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Manages parties (groups) and shared kill experience.
|
||||
* <p>
|
||||
* When a member kills a mob, the other online members receive
|
||||
* {@value #SHARED_XP_FRACTION_PERCENT}% of the XP the killer gained.
|
||||
*/
|
||||
public final class GroupService implements Service {
|
||||
|
||||
public static final int SHARED_XP_FRACTION_PERCENT = 40;
|
||||
private static final double SHARED_XP_FRACTION = SHARED_XP_FRACTION_PERCENT / 100.0;
|
||||
|
||||
private final HytaleLogger logger;
|
||||
private final GroupRepository repository;
|
||||
private final PlayerSessionService sessions;
|
||||
private final ProgressionService progression;
|
||||
|
||||
private final Map<UUID, Group> groupsById = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, UUID> groupByPlayer = new ConcurrentHashMap<>();
|
||||
|
||||
public GroupService(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull GroupRepository repository,
|
||||
@Nonnull PlayerSessionService sessions,
|
||||
@Nonnull ProgressionService progression) {
|
||||
this.logger = plugin.getLogger();
|
||||
this.repository = repository;
|
||||
this.sessions = sessions;
|
||||
this.progression = progression;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
try {
|
||||
for (Group group : repository.loadAll()) {
|
||||
groupsById.put(group.getId(), group);
|
||||
for (UUID member : group.getMembers()) {
|
||||
groupByPlayer.put(member, group.getId());
|
||||
}
|
||||
}
|
||||
logger.at(Level.INFO).log("Loaded %d MMORPG groups", groupsById.size());
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log("Failed to load groups: %s", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
groupsById.clear();
|
||||
groupByPlayer.clear();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Group getGroupOf(@Nonnull UUID player) {
|
||||
UUID groupId = groupByPlayer.get(player);
|
||||
return groupId == null ? null : groupsById.get(groupId);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Group getById(@Nonnull UUID groupId) {
|
||||
return groupsById.get(groupId);
|
||||
}
|
||||
|
||||
/** Invites {@code target} to {@code inviter}'s group, creating the group if needed. */
|
||||
@Nonnull
|
||||
public InviteResult invite(@Nonnull UUID inviter, @Nonnull UUID target) {
|
||||
if (inviter.equals(target)) {
|
||||
return InviteResult.SELF;
|
||||
}
|
||||
if (getGroupOf(target) != null) {
|
||||
return InviteResult.TARGET_ALREADY_GROUPED;
|
||||
}
|
||||
Group group = getGroupOf(inviter);
|
||||
if (group == null) {
|
||||
group = createGroup(inviter);
|
||||
} else if (!group.isOwner(inviter)) {
|
||||
return InviteResult.NOT_OWNER;
|
||||
}
|
||||
group.getPendingInvites().add(target);
|
||||
return InviteResult.INVITED;
|
||||
}
|
||||
|
||||
/** Accepts a pending invitation. */
|
||||
@Nonnull
|
||||
public AcceptResult accept(@Nonnull UUID player) {
|
||||
if (getGroupOf(player) != null) {
|
||||
return AcceptResult.ALREADY_GROUPED;
|
||||
}
|
||||
for (Group group : groupsById.values()) {
|
||||
if (group.getPendingInvites().remove(player)) {
|
||||
addMember(group, player);
|
||||
return AcceptResult.JOINED;
|
||||
}
|
||||
}
|
||||
return AcceptResult.NO_INVITE;
|
||||
}
|
||||
|
||||
/** Removes the player from their group; disbands it if the owner leaves or it becomes empty. */
|
||||
public boolean leave(@Nonnull UUID player) {
|
||||
Group group = getGroupOf(player);
|
||||
if (group == null) {
|
||||
return false;
|
||||
}
|
||||
if (group.isOwner(player)) {
|
||||
disband(group);
|
||||
return true;
|
||||
}
|
||||
removeMember(group, player);
|
||||
if (group.getMembers().size() <= 1) {
|
||||
disband(group);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public KickResult kick(@Nonnull UUID owner, @Nonnull UUID target) {
|
||||
Group group = getGroupOf(owner);
|
||||
if (group == null || !group.isOwner(owner)) {
|
||||
return KickResult.NOT_OWNER;
|
||||
}
|
||||
if (owner.equals(target)) {
|
||||
return KickResult.CANNOT_KICK_SELF;
|
||||
}
|
||||
if (!group.getMembers().contains(target)) {
|
||||
return KickResult.NOT_MEMBER;
|
||||
}
|
||||
removeMember(group, target);
|
||||
if (group.getMembers().size() <= 1) {
|
||||
disband(group);
|
||||
}
|
||||
return KickResult.KICKED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distributes shared XP to the killer's online group mates.
|
||||
*
|
||||
* @return number of members who received XP.
|
||||
*/
|
||||
public int shareKillExperience(@Nonnull UUID killer, long killerGainedXp) {
|
||||
if (killerGainedXp <= 0) {
|
||||
return 0;
|
||||
}
|
||||
Group group = getGroupOf(killer);
|
||||
if (group == null) {
|
||||
return 0;
|
||||
}
|
||||
long shared = Math.round(killerGainedXp * SHARED_XP_FRACTION);
|
||||
if (shared <= 0) {
|
||||
return 0;
|
||||
}
|
||||
int rewarded = 0;
|
||||
for (UUID member : group.membersExcept(killer)) {
|
||||
PlayerProfile profile = sessions.getSession(member);
|
||||
if (profile == null) {
|
||||
continue;
|
||||
}
|
||||
progression.grantExperience(profile, shared, false);
|
||||
rewarded++;
|
||||
}
|
||||
return rewarded;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Group createGroup(@Nonnull UUID owner) {
|
||||
Group group = new Group(UUID.randomUUID(), owner, System.currentTimeMillis());
|
||||
groupsById.put(group.getId(), group);
|
||||
groupByPlayer.put(owner, group.getId());
|
||||
persistGroup(group);
|
||||
addMemberPersistence(group.getId(), owner);
|
||||
setProfileGroup(owner, group.getId());
|
||||
return group;
|
||||
}
|
||||
|
||||
private void addMember(@Nonnull Group group, @Nonnull UUID player) {
|
||||
group.getMembers().add(player);
|
||||
groupByPlayer.put(player, group.getId());
|
||||
addMemberPersistence(group.getId(), player);
|
||||
setProfileGroup(player, group.getId());
|
||||
}
|
||||
|
||||
private void removeMember(@Nonnull Group group, @Nonnull UUID player) {
|
||||
group.getMembers().remove(player);
|
||||
groupByPlayer.remove(player);
|
||||
try {
|
||||
repository.removeMember(group.getId(), player);
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log("Failed to remove group member: %s", e.getMessage());
|
||||
}
|
||||
setProfileGroup(player, null);
|
||||
}
|
||||
|
||||
private void disband(@Nonnull Group group) {
|
||||
for (UUID member : group.getMembers()) {
|
||||
groupByPlayer.remove(member);
|
||||
setProfileGroup(member, null);
|
||||
}
|
||||
groupsById.remove(group.getId());
|
||||
try {
|
||||
repository.deleteGroup(group.getId());
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log("Failed to delete group: %s", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void persistGroup(@Nonnull Group group) {
|
||||
try {
|
||||
repository.insertGroup(group);
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log("Failed to persist group: %s", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void addMemberPersistence(@Nonnull UUID groupId, @Nonnull UUID player) {
|
||||
try {
|
||||
repository.addMember(groupId, player);
|
||||
} catch (SQLException e) {
|
||||
logger.at(Level.WARNING).log("Failed to persist group member: %s", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void setProfileGroup(@Nonnull UUID player, @Nullable UUID groupId) {
|
||||
PlayerProfile profile = sessions.getSession(player);
|
||||
if (profile != null) {
|
||||
profile.setGroupId(groupId == null ? null : groupId.toString());
|
||||
profile.touch();
|
||||
}
|
||||
}
|
||||
|
||||
public enum InviteResult {
|
||||
INVITED, SELF, NOT_OWNER, TARGET_ALREADY_GROUPED
|
||||
}
|
||||
|
||||
public enum AcceptResult {
|
||||
JOINED, NO_INVITE, ALREADY_GROUPED
|
||||
}
|
||||
|
||||
public enum KickResult {
|
||||
KICKED, NOT_OWNER, NOT_MEMBER, CANNOT_KICK_SELF
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.disklexar.mmorpg.rpg.job;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Registry of all jobs.
|
||||
*/
|
||||
public final class JobCatalog {
|
||||
|
||||
/** Money granted per mob killed while the Monster Slayer job is active. */
|
||||
public static final long MONSTER_SLAYER_REWARD = 1L;
|
||||
/** Money granted per block broken while the Miner job is active. */
|
||||
public static final long MINER_REWARD = 1L;
|
||||
|
||||
public static final JobDefinition MONSTER_SLAYER = new JobDefinition(
|
||||
"monster_slayer",
|
||||
"Tueur de monstre",
|
||||
"Gagne 1 money à chaque monstre tué.");
|
||||
|
||||
public static final JobDefinition MINER = new JobDefinition(
|
||||
"miner",
|
||||
"Mineur",
|
||||
"Gagne 1 money à chaque bloc cassé.");
|
||||
|
||||
private static final Map<String, JobDefinition> BY_ID = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
register(MONSTER_SLAYER);
|
||||
register(MINER);
|
||||
}
|
||||
|
||||
private JobCatalog() {
|
||||
}
|
||||
|
||||
private static void register(@Nonnull JobDefinition job) {
|
||||
BY_ID.put(job.id().toLowerCase(), job);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JobDefinition byId(@Nullable String id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return BY_ID.get(id.toLowerCase());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static List<JobDefinition> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.disklexar.mmorpg.rpg.job;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* A job a player can join to earn money from a specific activity.
|
||||
*/
|
||||
public record JobDefinition(
|
||||
@Nonnull String id,
|
||||
@Nonnull String displayName,
|
||||
@Nonnull String description) {
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.disklexar.mmorpg.rpg.job;
|
||||
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Joining / leaving jobs.
|
||||
*/
|
||||
public final class JobService {
|
||||
|
||||
public boolean has(@Nonnull PlayerProfile profile, @Nonnull String jobId) {
|
||||
return profile.getJobs().contains(jobId.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a job.
|
||||
*
|
||||
* @return {@code true} if the job exists and was newly joined.
|
||||
*/
|
||||
public boolean join(@Nonnull PlayerProfile profile, @Nonnull String jobId) {
|
||||
JobDefinition job = JobCatalog.byId(jobId);
|
||||
if (job == null) {
|
||||
return false;
|
||||
}
|
||||
boolean added = profile.getJobs().add(job.id());
|
||||
if (added) {
|
||||
profile.touch();
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a job.
|
||||
*
|
||||
* @return {@code true} if the job was active and was left.
|
||||
*/
|
||||
public boolean leave(@Nonnull PlayerProfile profile, @Nonnull String jobId) {
|
||||
JobDefinition job = JobCatalog.byId(jobId);
|
||||
if (job == null) {
|
||||
return false;
|
||||
}
|
||||
boolean removed = profile.getJobs().remove(job.id());
|
||||
if (removed) {
|
||||
profile.touch();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.disklexar.mmorpg.rpg.power;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Registry of all passive powers.
|
||||
*/
|
||||
public final class PowerCatalog {
|
||||
|
||||
/** Constant out-of-combat movement bonus. */
|
||||
public static final double SPRINTER_SPEED_BONUS = 0.10;
|
||||
/** Damage bonus applied for a short window after being hit. */
|
||||
public static final double RESILIENT_DAMAGE_BONUS = 0.10;
|
||||
/** Window during which {@link #RESILIENT} grants its bonus, in milliseconds. */
|
||||
public static final long RESILIENT_WINDOW_MILLIS = 5_000L;
|
||||
|
||||
public static final PowerDefinition SPRINTER = new PowerDefinition(
|
||||
"sprinter",
|
||||
"Sprinteur",
|
||||
"+10% de vitesse en permanence hors combat.");
|
||||
|
||||
public static final PowerDefinition RESILIENT = new PowerDefinition(
|
||||
"resilient",
|
||||
"Résilient",
|
||||
"+10% de dégâts pendant 5s après avoir été touché.");
|
||||
|
||||
private static final Map<String, PowerDefinition> BY_ID = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
register(SPRINTER);
|
||||
register(RESILIENT);
|
||||
}
|
||||
|
||||
private PowerCatalog() {
|
||||
}
|
||||
|
||||
private static void register(@Nonnull PowerDefinition power) {
|
||||
BY_ID.put(power.id().toLowerCase(), power);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static PowerDefinition byId(@Nullable String id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return BY_ID.get(id.toLowerCase());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static List<PowerDefinition> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.disklexar.mmorpg.rpg.power;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* A passive power a player can own. Players start with none.
|
||||
*/
|
||||
public record PowerDefinition(
|
||||
@Nonnull String id,
|
||||
@Nonnull String displayName,
|
||||
@Nonnull String description) {
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.disklexar.mmorpg.rpg.power;
|
||||
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Granting / removing passive powers. Players start with none.
|
||||
*/
|
||||
public final class PowerService {
|
||||
|
||||
public boolean has(@Nonnull PlayerProfile profile, @Nonnull String powerId) {
|
||||
return profile.getPowers().contains(powerId.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a power.
|
||||
*
|
||||
* @return {@code true} if the power exists and was newly added.
|
||||
*/
|
||||
public boolean grant(@Nonnull PlayerProfile profile, @Nonnull String powerId) {
|
||||
PowerDefinition power = PowerCatalog.byId(powerId);
|
||||
if (power == null) {
|
||||
return false;
|
||||
}
|
||||
boolean added = profile.getPowers().add(power.id());
|
||||
if (added) {
|
||||
profile.touch();
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a power.
|
||||
*
|
||||
* @return {@code true} if the power was present and removed.
|
||||
*/
|
||||
public boolean remove(@Nonnull PlayerProfile profile, @Nonnull String powerId) {
|
||||
PowerDefinition power = PowerCatalog.byId(powerId);
|
||||
if (power == null) {
|
||||
return false;
|
||||
}
|
||||
boolean removed = profile.getPowers().remove(power.id());
|
||||
if (removed) {
|
||||
profile.touch();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.disklexar.mmorpg.rpg.race;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Registry of all races. Every player defaults to {@link #HUMAN}.
|
||||
*/
|
||||
public final class RaceCatalog {
|
||||
|
||||
public static final String DEFAULT_RACE_ID = "human";
|
||||
|
||||
public static final RaceDefinition HUMAN = new RaceDefinition(
|
||||
DEFAULT_RACE_ID,
|
||||
"Humain",
|
||||
"Race par défaut. Gagne le double d'expérience.",
|
||||
2.0);
|
||||
|
||||
private static final Map<String, RaceDefinition> BY_ID = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
register(HUMAN);
|
||||
}
|
||||
|
||||
private RaceCatalog() {
|
||||
}
|
||||
|
||||
private static void register(@Nonnull RaceDefinition race) {
|
||||
BY_ID.put(race.id().toLowerCase(), race);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static RaceDefinition byId(String id) {
|
||||
if (id == null) {
|
||||
return HUMAN;
|
||||
}
|
||||
return BY_ID.getOrDefault(id.toLowerCase(), HUMAN);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static List<RaceDefinition> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.disklexar.mmorpg.rpg.race;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* A playable race with its global XP multiplier.
|
||||
*/
|
||||
public record RaceDefinition(
|
||||
@Nonnull String id,
|
||||
@Nonnull String displayName,
|
||||
@Nonnull String description,
|
||||
double xpMultiplier) {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.disklexar.mmorpg.rpg.race;
|
||||
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
/**
|
||||
* Race access. Every player defaults to Humain (set at profile creation).
|
||||
*/
|
||||
public final class RaceService {
|
||||
|
||||
@Nonnull
|
||||
public RaceDefinition getRace(@Nonnull PlayerProfile profile) {
|
||||
return RaceCatalog.byId(profile.getRaceId());
|
||||
}
|
||||
|
||||
/** Ensures the profile has a valid race, defaulting to Humain. */
|
||||
public void ensureDefault(@Nonnull PlayerProfile profile) {
|
||||
if (RaceCatalog.byId(profile.getRaceId()) == RaceCatalog.HUMAN
|
||||
&& !RaceCatalog.DEFAULT_RACE_ID.equals(profile.getRaceId())) {
|
||||
profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID);
|
||||
profile.touch();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setRace(@Nonnull PlayerProfile profile, @Nonnull String raceId) {
|
||||
RaceDefinition race = RaceCatalog.byId(raceId);
|
||||
if (!race.id().equalsIgnoreCase(raceId)) {
|
||||
return false;
|
||||
}
|
||||
profile.setRaceId(race.id());
|
||||
profile.touch();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Social systems (parties, guilds, friends).
|
||||
* <p>
|
||||
* Disabled by default via {@code Features.Guilds} in config.json.
|
||||
* Planned: guild creation, ranks, shared storage, party instances.
|
||||
*/
|
||||
package com.disklexar.mmorpg.social;
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.disklexar.mmorpg.ui;
|
||||
|
||||
import com.disklexar.mmorpg.combat.AbilityService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.progression.ProgressionService;
|
||||
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
|
||||
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud;
|
||||
import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Persistent MMORPG HUD: vitals (health, stamina, XP) and class ability bar (top-left).
|
||||
*/
|
||||
public final class AbilityBarHud extends CustomUIHud {
|
||||
|
||||
public static final String KEY = "mmorpg_ability_bar";
|
||||
private static final String DOCUMENT = "MmorpgAbilityBar.ui";
|
||||
public AbilityBarHud(@Nonnull PlayerRef playerRef) {
|
||||
super(playerRef, KEY);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void build(@Nonnull UICommandBuilder ui) {
|
||||
ui.append(DOCUMENT);
|
||||
}
|
||||
|
||||
public void sync(
|
||||
@Nonnull PlayerProfile profile,
|
||||
@Nonnull PlayerClass playerClass,
|
||||
@Nonnull AbilityService abilities,
|
||||
@Nonnull ProgressionService progression,
|
||||
@Nullable PlayerVitalsReader.Vitals vitals,
|
||||
@Nonnull UUID playerId) {
|
||||
UICommandBuilder ui = new UICommandBuilder();
|
||||
|
||||
ui.set("#HeaderLabel.Text", escape(
|
||||
"Nv." + profile.getLevel() + " — " + playerClass.displayName()));
|
||||
|
||||
if (vitals != null) {
|
||||
applyBar(ui, "#HealthBar", vitals.current(), vitals.max());
|
||||
ui.set("#HealthValue.Text", escape(vitals.current() + " / " + vitals.max()));
|
||||
|
||||
applyBar(ui, "#StaminaBar", vitals.staminaCurrent(), vitals.staminaMax());
|
||||
ui.set("#StaminaValue.Text", escape(
|
||||
vitals.staminaCurrent() + " / " + vitals.staminaMax()));
|
||||
}
|
||||
|
||||
long xpCurrent = profile.getExperience();
|
||||
long xpMax = Math.max(1L, progression.xpForLevel(profile.getLevel()));
|
||||
applyBar(ui, "#XpBar", xpCurrent, xpMax);
|
||||
ui.set("#XpValue.Text", escape(xpCurrent + " / " + xpMax));
|
||||
|
||||
for (AbilityDefinition ability : playerClass.abilities()) {
|
||||
int slot = ability.slot();
|
||||
long remaining = abilities.cooldownRemaining(playerId, ability.id());
|
||||
String label = remaining > 0
|
||||
? ability.displayName() + " (" + (remaining / 1000 + 1) + "s)"
|
||||
: ability.displayName();
|
||||
ui.set("#Slot" + slot + "Name.Text", escape(label));
|
||||
}
|
||||
|
||||
update(false, ui);
|
||||
}
|
||||
|
||||
private static void applyBar(
|
||||
@Nonnull UICommandBuilder ui,
|
||||
@Nonnull String barSelector,
|
||||
long current,
|
||||
long max) {
|
||||
float value = max <= 0 ? 0f : Math.min(1f, (float) current / max);
|
||||
ui.set(barSelector + ".Value", value);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String escape(@Nonnull String text) {
|
||||
return text.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.disklexar.mmorpg.ui;
|
||||
|
||||
import com.disklexar.mmorpg.MmorpgPlugin;
|
||||
import com.disklexar.mmorpg.combat.AbilityService;
|
||||
import com.disklexar.mmorpg.combat.MmorpgScheduler;
|
||||
import com.disklexar.mmorpg.core.service.Service;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayer;
|
||||
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
|
||||
import com.disklexar.mmorpg.player.PlayerSessionService;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.disklexar.mmorpg.progression.ProgressionService;
|
||||
import com.disklexar.mmorpg.rpg.clazz.ClassService;
|
||||
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Shows and refreshes the native ability-bar HUD for players with an MMORPG class.
|
||||
*/
|
||||
public final class AbilityBarService implements Service {
|
||||
|
||||
private final HytaleLogger logger;
|
||||
private final ClassService classService;
|
||||
private final AbilityService abilityService;
|
||||
private final OnlinePlayerRegistry onlinePlayers;
|
||||
private final PlayerSessionService sessions;
|
||||
private final ProgressionService progression;
|
||||
private final MmorpgScheduler scheduler;
|
||||
|
||||
private final Map<UUID, AbilityBarHud> activeHuds = new ConcurrentHashMap<>();
|
||||
private ScheduledFuture<?> refreshTask;
|
||||
|
||||
public AbilityBarService(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull ClassService classService,
|
||||
@Nonnull AbilityService abilityService,
|
||||
@Nonnull OnlinePlayerRegistry onlinePlayers,
|
||||
@Nonnull PlayerSessionService sessions,
|
||||
@Nonnull ProgressionService progression,
|
||||
@Nonnull MmorpgScheduler scheduler) {
|
||||
this.logger = plugin.getLogger();
|
||||
this.classService = classService;
|
||||
this.abilityService = abilityService;
|
||||
this.onlinePlayers = onlinePlayers;
|
||||
this.sessions = sessions;
|
||||
this.progression = progression;
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
refreshTask = scheduler.runRepeating(this::refreshAll, 5_000L, 1_000L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
if (refreshTask != null) {
|
||||
refreshTask.cancel(false);
|
||||
refreshTask = null;
|
||||
}
|
||||
activeHuds.clear();
|
||||
}
|
||||
|
||||
public void sync(@Nonnull UUID playerUuid, @Nonnull PlayerProfile profile) {
|
||||
OnlinePlayer online = onlinePlayers.get(playerUuid);
|
||||
if (online == null) {
|
||||
return;
|
||||
}
|
||||
sync(online, profile);
|
||||
}
|
||||
|
||||
public void sync(@Nonnull OnlinePlayer online, @Nonnull PlayerProfile profile) {
|
||||
PlayerClass playerClass = classService.getClass(profile);
|
||||
if (playerClass == null) {
|
||||
dismiss(online.uuid());
|
||||
return;
|
||||
}
|
||||
|
||||
online.world().execute(() -> applyHud(online, profile, playerClass));
|
||||
}
|
||||
|
||||
public void dismiss(@Nonnull UUID playerUuid) {
|
||||
OnlinePlayer online = onlinePlayers.get(playerUuid);
|
||||
if (online == null) {
|
||||
activeHuds.remove(playerUuid);
|
||||
return;
|
||||
}
|
||||
dismiss(online);
|
||||
}
|
||||
|
||||
public void dismiss(@Nonnull OnlinePlayer online) {
|
||||
AbilityBarHud hud = activeHuds.remove(online.uuid());
|
||||
if (hud == null) {
|
||||
return;
|
||||
}
|
||||
online.world().execute(() -> {
|
||||
Player player = resolvePlayer(online.world(), online.uuid());
|
||||
if (player != null) {
|
||||
player.getHudManager().removeCustomHud(online.playerRef(), AbilityBarHud.KEY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshAll() {
|
||||
for (OnlinePlayer online : onlinePlayers.all()) {
|
||||
PlayerProfile profile = sessions.getSession(online.uuid());
|
||||
if (profile == null || !classService.hasClass(profile)) {
|
||||
if (activeHuds.containsKey(online.uuid())) {
|
||||
dismiss(online.uuid());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
AbilityBarHud hud = activeHuds.get(online.uuid());
|
||||
if (hud == null) {
|
||||
sync(online, profile);
|
||||
continue;
|
||||
}
|
||||
PlayerClass playerClass = classService.getClass(profile);
|
||||
if (playerClass == null) {
|
||||
continue;
|
||||
}
|
||||
online.world().execute(() -> {
|
||||
if (onlinePlayers.get(online.uuid()) != online) {
|
||||
return;
|
||||
}
|
||||
if (resolvePlayer(online.world(), online.uuid()) == null) {
|
||||
activeHuds.remove(online.uuid());
|
||||
return;
|
||||
}
|
||||
pushUpdate(online, profile, playerClass, hud);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void applyHud(
|
||||
@Nonnull OnlinePlayer online,
|
||||
@Nonnull PlayerProfile profile,
|
||||
@Nonnull PlayerClass playerClass) {
|
||||
try {
|
||||
if (onlinePlayers.get(online.uuid()) == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = resolvePlayer(online.world(), online.uuid());
|
||||
if (player == null) {
|
||||
logger.at(Level.FINE).log(
|
||||
"Ability bar HUD deferred for %s: player not in world store yet",
|
||||
online.playerRef().getUsername());
|
||||
return;
|
||||
}
|
||||
|
||||
AbilityBarHud hud = activeHuds.get(online.uuid());
|
||||
if (hud == null) {
|
||||
hud = new AbilityBarHud(online.playerRef());
|
||||
player.getHudManager().addCustomHud(online.playerRef(), hud);
|
||||
activeHuds.put(online.uuid(), hud);
|
||||
logger.at(Level.INFO).log(
|
||||
"MMORPG HUD shown for %s",
|
||||
online.playerRef().getUsername());
|
||||
}
|
||||
pushUpdate(online, profile, playerClass, hud);
|
||||
} catch (Throwable t) {
|
||||
activeHuds.remove(online.uuid());
|
||||
logger.at(Level.WARNING).log(
|
||||
"Ability bar HUD failed for %s: %s",
|
||||
online.playerRef().getUsername(),
|
||||
t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void pushUpdate(
|
||||
@Nonnull OnlinePlayer online,
|
||||
@Nonnull PlayerProfile profile,
|
||||
@Nonnull PlayerClass playerClass,
|
||||
@Nonnull AbilityBarHud hud) {
|
||||
if (!activeHuds.containsKey(online.uuid()) || onlinePlayers.get(online.uuid()) == null) {
|
||||
return;
|
||||
}
|
||||
World world = online.world();
|
||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||
Ref<EntityStore> entityRef = resolveEntityRef(world, online.uuid());
|
||||
PlayerVitalsReader.Vitals vitals = entityRef == null
|
||||
? null
|
||||
: PlayerVitalsReader.read(store, entityRef);
|
||||
hud.sync(profile, playerClass, abilityService, progression, vitals, profile.getUuid());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Ref<EntityStore> resolveEntityRef(@Nonnull World world, @Nonnull UUID playerUuid) {
|
||||
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
||||
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
|
||||
continue;
|
||||
}
|
||||
Ref<EntityStore> entityRef = playerRef.getReference();
|
||||
if (entityRef != null && entityRef.isValid()) {
|
||||
return entityRef;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the live {@link Player} from the world's player list instead of a cached
|
||||
* {@link Ref} that may be unset or invalidated after {@code ClientReady}.
|
||||
*/
|
||||
@Nullable
|
||||
private static Player resolvePlayer(@Nonnull World world, @Nonnull UUID playerUuid) {
|
||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
||||
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
|
||||
continue;
|
||||
}
|
||||
Ref<EntityStore> entityRef = playerRef.getReference();
|
||||
if (entityRef == null || !entityRef.isValid()) {
|
||||
continue;
|
||||
}
|
||||
Player player = store.getComponent(entityRef, Player.getComponentType());
|
||||
if (player != null) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.disklexar.mmorpg.ui;
|
||||
|
||||
import com.disklexar.mmorpg.player.PlayerInfoView;
|
||||
import com.disklexar.mmorpg.player.model.PlayerProfile;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.pages.CustomUIPage;
|
||||
import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
|
||||
import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Custom UI page listing every MMORPG attribute of the player.
|
||||
* <p>
|
||||
* Custom UI documents must already exist on the client (they ship in the game's {@code Assets.zip}),
|
||||
* so we reuse the always-present {@code Pages/WarpListPage.ui} layout — a titled window with a
|
||||
* scrolling list container ({@code #WarpList}) and a back button — and fill that container with one
|
||||
* label per attribute via {@link UICommandBuilder#appendInline(String, String)}. This is the same
|
||||
* mechanism the builtin pages use to inject rows, and it avoids the previous crash that came from
|
||||
* passing inline markup to {@link UICommandBuilder#append(String)} (which expects a document path).
|
||||
*/
|
||||
public final class PlayerInfoPage extends CustomUIPage {
|
||||
|
||||
/** Reliable layout: reuses the always-present base-game document. */
|
||||
private static final String RELIABLE_DOCUMENT = "Pages/WarpListPage.ui";
|
||||
private static final String RELIABLE_LIST = "#WarpList";
|
||||
|
||||
/** Designed layout shipped in the plugin asset pack. */
|
||||
private static final String CUSTOM_DOCUMENT = "Pages/MmorpgPlayerInfo.ui";
|
||||
private static final String CUSTOM_LIST = "#ProfileList";
|
||||
|
||||
private final PlayerProfile profile;
|
||||
private final boolean custom;
|
||||
|
||||
public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile) {
|
||||
this(playerRef, profile, false);
|
||||
}
|
||||
|
||||
public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile, boolean custom) {
|
||||
super(playerRef, CustomPageLifetime.CanDismiss);
|
||||
this.profile = profile;
|
||||
this.custom = custom;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void build(
|
||||
@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull UICommandBuilder ui,
|
||||
@Nonnull UIEventBuilder events,
|
||||
@Nonnull Store<EntityStore> store) {
|
||||
String document = custom ? CUSTOM_DOCUMENT : RELIABLE_DOCUMENT;
|
||||
String list = custom ? CUSTOM_LIST : RELIABLE_LIST;
|
||||
|
||||
ui.append(document);
|
||||
ui.clear(list);
|
||||
|
||||
ui.appendInline(list, title("=== Profil MMORPG ==="));
|
||||
List<PlayerInfoView.Entry> entries = PlayerInfoView.entries(profile);
|
||||
for (PlayerInfoView.Entry entry : entries) {
|
||||
ui.appendInline(list, row(entry.label(), entry.value()));
|
||||
}
|
||||
ui.appendInline(list, hint("(Échap pour fermer)"));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String title(@Nonnull String text) {
|
||||
return "Label { Text: \"" + escape(text)
|
||||
+ "\"; Style: (HorizontalAlignment: Center, RenderBold: true, FontSize: 18);"
|
||||
+ " Anchor: (Bottom: 8); }";
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String row(@Nonnull String label, @Nonnull String value) {
|
||||
return "Label { Text: \"" + escape(label + " : " + value)
|
||||
+ "\"; Style: (FontSize: 14, Wrap: true); Anchor: (Bottom: 3); }";
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String hint(@Nonnull String text) {
|
||||
return "Label { Text: \"" + escape(text)
|
||||
+ "\"; Style: (HorizontalAlignment: Center, FontSize: 12, TextColor: #878e9c);"
|
||||
+ " Anchor: (Top: 8); }";
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static String escape(@Nonnull String text) {
|
||||
return text.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.disklexar.mmorpg.ui;
|
||||
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap;
|
||||
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatValue;
|
||||
import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Reads live health and stamina from the vanilla entity stat map.
|
||||
*/
|
||||
public final class PlayerVitalsReader {
|
||||
|
||||
public record Vitals(int current, int max, int staminaCurrent, int staminaMax) {
|
||||
}
|
||||
|
||||
private PlayerVitalsReader() {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static Vitals read(
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> entityRef) {
|
||||
EntityStatMap stats = store.getComponent(entityRef, EntityStatMap.getComponentType());
|
||||
if (stats == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
EntityStatValue health = stats.get(DefaultEntityStatTypes.getHealth());
|
||||
EntityStatValue stamina = stats.get(DefaultEntityStatTypes.getStamina());
|
||||
if (health == null || stamina == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Vitals(
|
||||
Math.round(health.get()),
|
||||
Math.round(health.getMax()),
|
||||
Math.round(stamina.get()),
|
||||
Math.round(stamina.getMax()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
@HealthBarFill = PatchStyle(Color: #e74c3c);
|
||||
@HealthBarBg = PatchStyle(Color: #2a1515);
|
||||
@StaminaBarFill = PatchStyle(Color: #2ecc71);
|
||||
@StaminaBarBg = PatchStyle(Color: #152a1c);
|
||||
@XpBarFill = PatchStyle(Color: #4a9eff);
|
||||
@XpBarBg = PatchStyle(Color: #152030);
|
||||
@VitalsPanelWidth = 260;
|
||||
@VitalsBarWidth = 244;
|
||||
@AbilitySlotSize = 68;
|
||||
@AbilityPanelPad = 3;
|
||||
@AbilityPanelWidth = @AbilitySlotSize + (@AbilityPanelPad * 2);
|
||||
|
||||
Group {
|
||||
LayoutMode: Top;
|
||||
Anchor: (Left: 16, Top: 16, Width: @VitalsPanelWidth);
|
||||
|
||||
Label #HeaderLabel {
|
||||
Style: (FontSize: 14, RenderBold: true, TextColor: #f5c842);
|
||||
Text: "Nv.1 — Classe";
|
||||
Anchor: (Left: 0, Bottom: 8);
|
||||
}
|
||||
|
||||
Group #VitalsPanel {
|
||||
LayoutMode: Top;
|
||||
Padding: (Full: 8);
|
||||
Background: #0d0f14(0.92);
|
||||
Anchor: (Left: 0, Width: @VitalsPanelWidth, Bottom: 8);
|
||||
|
||||
Group #HealthRow {
|
||||
LayoutMode: Top;
|
||||
Anchor: (Bottom: 6);
|
||||
|
||||
Label #HealthTitle {
|
||||
Style: (FontSize: 10, RenderBold: true, TextColor: #ff7b7b);
|
||||
Text: "Vie";
|
||||
Anchor: (Bottom: 2);
|
||||
}
|
||||
|
||||
ProgressBar #HealthBar {
|
||||
Anchor: (Width: @VitalsBarWidth, Height: 12);
|
||||
Bar: @HealthBarFill;
|
||||
Background: @HealthBarBg;
|
||||
Value: 1.0;
|
||||
Direction: End;
|
||||
}
|
||||
|
||||
Label #HealthValue {
|
||||
Style: (FontSize: 9, TextColor: #f0f0f0);
|
||||
Text: "0 / 0";
|
||||
Anchor: (Top: 2);
|
||||
}
|
||||
}
|
||||
|
||||
Group #StaminaRow {
|
||||
LayoutMode: Top;
|
||||
Anchor: (Bottom: 6);
|
||||
|
||||
Label #StaminaTitle {
|
||||
Style: (FontSize: 10, RenderBold: true, TextColor: #7bdc9a);
|
||||
Text: "Endurance";
|
||||
Anchor: (Bottom: 2);
|
||||
}
|
||||
|
||||
ProgressBar #StaminaBar {
|
||||
Anchor: (Width: @VitalsBarWidth, Height: 12);
|
||||
Bar: @StaminaBarFill;
|
||||
Background: @StaminaBarBg;
|
||||
Value: 1.0;
|
||||
Direction: End;
|
||||
}
|
||||
|
||||
Label #StaminaValue {
|
||||
Style: (FontSize: 9, TextColor: #f0f0f0);
|
||||
Text: "0 / 0";
|
||||
Anchor: (Top: 2);
|
||||
}
|
||||
}
|
||||
|
||||
Group #XpRow {
|
||||
LayoutMode: Top;
|
||||
|
||||
Label #XpTitle {
|
||||
Style: (FontSize: 10, RenderBold: true, TextColor: #8eb6ff);
|
||||
Text: "Expérience";
|
||||
Anchor: (Bottom: 2);
|
||||
}
|
||||
|
||||
ProgressBar #XpBar {
|
||||
Anchor: (Width: @VitalsBarWidth, Height: 12);
|
||||
Bar: @XpBarFill;
|
||||
Background: @XpBarBg;
|
||||
Value: 0.0;
|
||||
Direction: End;
|
||||
}
|
||||
|
||||
Label #XpValue {
|
||||
Style: (FontSize: 9, TextColor: #f0f0f0);
|
||||
Text: "0 / 0";
|
||||
Anchor: (Top: 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label #BarTitle {
|
||||
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
|
||||
Text: "CAPACITÉS";
|
||||
Anchor: (Left: 0, Bottom: 4);
|
||||
}
|
||||
|
||||
Group #AbilityBarPanel {
|
||||
LayoutMode: Top;
|
||||
Padding: (Full: @AbilityPanelPad);
|
||||
Background: #0d0f14(0.92);
|
||||
Anchor: (Left: 0, Width: @AbilityPanelWidth);
|
||||
|
||||
Group #AbilitySlot1 {
|
||||
Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5);
|
||||
Background: #2a3140;
|
||||
LayoutMode: Top;
|
||||
Padding: (Full: 4);
|
||||
|
||||
Label #Slot1Key {
|
||||
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
|
||||
Text: "[1]";
|
||||
Anchor: (Bottom: 2);
|
||||
}
|
||||
|
||||
Label #Slot1Name {
|
||||
Style: (FontSize: 10, TextColor: #ffffff, Wrap: true);
|
||||
Text: "Capacité 1";
|
||||
}
|
||||
}
|
||||
|
||||
Group #AbilitySlot2 {
|
||||
Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5);
|
||||
Background: #2a3140;
|
||||
LayoutMode: Top;
|
||||
Padding: (Full: 4);
|
||||
|
||||
Label #Slot2Key {
|
||||
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
|
||||
Text: "[2]";
|
||||
Anchor: (Bottom: 2);
|
||||
}
|
||||
|
||||
Label #Slot2Name {
|
||||
Style: (FontSize: 10, TextColor: #ffffff, Wrap: true);
|
||||
Text: "Capacité 2";
|
||||
}
|
||||
}
|
||||
|
||||
Group #AbilitySlot3 {
|
||||
Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize);
|
||||
Background: #2a3140;
|
||||
LayoutMode: Top;
|
||||
Padding: (Full: 4);
|
||||
|
||||
Label #Slot3Key {
|
||||
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
|
||||
Text: "[3]";
|
||||
Anchor: (Bottom: 2);
|
||||
}
|
||||
|
||||
Label #Slot3Name {
|
||||
Style: (FontSize: 10, TextColor: #ffffff, Wrap: true);
|
||||
Text: "Capacité 3";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
$C = "../Common.ui";
|
||||
|
||||
$C.@PageOverlay {}
|
||||
|
||||
$C.@Container {
|
||||
Anchor: (Width: 600, Height: 700);
|
||||
|
||||
#Title {
|
||||
Group {
|
||||
$C.@Title {
|
||||
@Text = %server.customUI.warpListPage.warps;
|
||||
}
|
||||
|
||||
$C.@HeaderSearch {}
|
||||
}
|
||||
}
|
||||
|
||||
#Content {
|
||||
LayoutMode: Left;
|
||||
|
||||
Group #ProfileList {
|
||||
FlexWeight: 1;
|
||||
LayoutMode: TopScrolling;
|
||||
ScrollbarStyle: $C.@DefaultScrollbarStyle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$C.@BackButton {}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Id": "Weapon_Battleaxe_Mithril",
|
||||
"Interactions": {
|
||||
"Ability1": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
|
||||
]
|
||||
},
|
||||
"Ability2": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
|
||||
]
|
||||
},
|
||||
"Ability3": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Id": "Weapon_Crossbow_Ancient_Steel",
|
||||
"Interactions": {
|
||||
"Ability1": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
|
||||
]
|
||||
},
|
||||
"Ability2": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
|
||||
]
|
||||
},
|
||||
"Ability3": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Id": "Weapon_Daggers_Mithril",
|
||||
"Interactions": {
|
||||
"Ability1": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
|
||||
]
|
||||
},
|
||||
"Ability2": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
|
||||
]
|
||||
},
|
||||
"Ability3": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Id": "Weapon_Longsword_Mithril",
|
||||
"Interactions": {
|
||||
"Ability1": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
|
||||
]
|
||||
},
|
||||
"Ability2": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
|
||||
]
|
||||
},
|
||||
"Ability3": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Id": "Weapon_Shortbow_Cobalt",
|
||||
"Interactions": {
|
||||
"Ability1": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
|
||||
]
|
||||
},
|
||||
"Ability2": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
|
||||
]
|
||||
},
|
||||
"Ability3": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Id": "Weapon_Staff_Mithril",
|
||||
"Interactions": {
|
||||
"Ability1": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
|
||||
]
|
||||
},
|
||||
"Ability2": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
|
||||
]
|
||||
},
|
||||
"Ability3": {
|
||||
"Interactions": [
|
||||
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Debug": false,
|
||||
"DefaultLevel": 1,
|
||||
"MaxPlayers": 500,
|
||||
"BaseXpPerLevel": 100,
|
||||
"KillExperience": 10,
|
||||
"Database": {
|
||||
"FileName": "mmorpg.db"
|
||||
},
|
||||
"Features": {
|
||||
"Economy": true,
|
||||
"Quests": false,
|
||||
"Guilds": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS player_profiles (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
experience INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
-- MMORPG progression schema: classes, powers, jobs, races, groups.
|
||||
-- New columns on player_profiles. SQLite ignores duplicate ADD COLUMN guarded by
|
||||
-- the migration runner (each migration is applied at most once via schema_migrations).
|
||||
|
||||
ALTER TABLE player_profiles ADD COLUMN class_id TEXT;
|
||||
ALTER TABLE player_profiles ADD COLUMN powers TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE player_profiles ADD COLUMN jobs TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE player_profiles ADD COLUMN guild_id TEXT;
|
||||
ALTER TABLE player_profiles ADD COLUMN group_id TEXT;
|
||||
ALTER TABLE player_profiles ADD COLUMN is_connected INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE player_profiles ADD COLUMN total_time_play INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE player_profiles ADD COLUMN last_date_connected INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE player_profiles ADD COLUMN date_creation INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE player_profiles ADD COLUMN money INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE player_profiles ADD COLUMN race_id TEXT NOT NULL DEFAULT 'human';
|
||||
|
||||
-- Backfill creation date for profiles that predate this migration.
|
||||
UPDATE player_profiles SET date_creation = created_at WHERE date_creation = 0;
|
||||
|
||||
-- Reference tables (definitions persisted for inspection / future editing).
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
weapons TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS powers (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
passive INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS races (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
xp_multiplier REAL NOT NULL DEFAULT 1.0
|
||||
);
|
||||
|
||||
-- Groups (parties): shared XP between members.
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_uuid TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
group_id TEXT NOT NULL,
|
||||
player_uuid TEXT NOT NULL,
|
||||
joined_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (group_id, player_uuid),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_player ON group_members(player_uuid);
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Group": "com.disklexar",
|
||||
"Name": "MMORPG",
|
||||
"Version": "0.2.6",
|
||||
"Description": "Serveur MMORPG Hytale — progression, persistance et systèmes sociaux",
|
||||
"Authors": [
|
||||
{
|
||||
"Name": "Disklexar"
|
||||
}
|
||||
],
|
||||
"Website": "https://github.com/disklexar/hytale-mmorpg",
|
||||
"DisabledByDefault": false,
|
||||
"IncludesAssetPack": true,
|
||||
"Dependencies": {},
|
||||
"OptionalDependencies": {},
|
||||
"ServerVersion": "*",
|
||||
"Main": "com.disklexar.mmorpg.MmorpgPlugin"
|
||||
}
|
||||
Reference in New Issue
Block a user