update
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-08 23:35:37 +02:00
parent 836499d10d
commit 96144d809c
78 changed files with 5871 additions and 502 deletions
@@ -7,12 +7,18 @@ 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.MmorpgAttackSpeedSystem;
import com.disklexar.mmorpg.combat.system.MmorpgDamageSystem;
import com.disklexar.mmorpg.combat.MmorpgScheduler;
import com.disklexar.mmorpg.combat.system.MmorpgDeathSystem;
import com.disklexar.mmorpg.combat.system.MmorpgPlayerDeathSystem;
import com.disklexar.mmorpg.combat.system.MmorpgPlayerRespawnSystem;
import com.disklexar.mmorpg.player.CharacterService;
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.MmorpgArcherShootInteraction;
import com.disklexar.mmorpg.interaction.MmorpgCastAbilityInteraction;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.PlayerSessionService;
@@ -20,6 +26,7 @@ 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.rpg.stats.CharacterStatService;
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;
@@ -85,6 +92,10 @@ public final class MmorpgPlugin extends JavaPlugin {
MmorpgCastAbilityInteraction.ID,
MmorpgCastAbilityInteraction.class,
MmorpgCastAbilityInteraction.CODEC);
getCodecRegistry(Interaction.CODEC).register(
MmorpgArcherShootInteraction.ID,
MmorpgArcherShootInteraction.class,
MmorpgArcherShootInteraction.CODEC);
getCommandRegistry().registerCommand(new MmorpgCommand(this));
getEventRegistry().registerGlobal(PlayerReadyEvent.class, connectionHandler::onPlayerReady);
@@ -107,12 +118,21 @@ public final class MmorpgPlugin extends JavaPlugin {
GroupService groups = registry.require(GroupService.class);
JobService jobs = registry.require(JobService.class);
EconomyService economy = registry.require(EconomyService.class);
CharacterService characterService = registry.require(CharacterService.class);
CharacterStatService characterStats = registry.require(CharacterStatService.class);
MmorpgScheduler scheduler = registry.require(MmorpgScheduler.class);
int killExperience = bootstrap.getConfig().getKillExperience();
getEntityStoreRegistry().registerSystem(
new MmorpgDamageSystem(sessions, combatState, combatBuffs, poisonService, onlinePlayers));
new MmorpgDamageSystem(sessions, combatState, combatBuffs, poisonService, onlinePlayers, characterStats));
getEntityStoreRegistry().registerSystem(
new MmorpgAttackSpeedSystem(sessions, characterStats));
getEntityStoreRegistry().registerSystem(
new MmorpgDeathSystem(sessions, progression, groups, jobs, economy, killExperience));
getEntityStoreRegistry().registerSystem(
new MmorpgPlayerDeathSystem(sessions, characterService));
getEntityStoreRegistry().registerSystem(
new MmorpgPlayerRespawnSystem(characterService, scheduler));
getEntityStoreRegistry().registerSystem(new MinerBlockBreakSystem(sessions, jobs, economy));
OpenCustomUIInteraction.registerSimple(this, PlayerInfoPage.class, "mmorpg_player_info", playerRef -> {
@@ -2,6 +2,7 @@ package com.disklexar.mmorpg.bootstrap;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.combat.AbilityService;
import com.disklexar.mmorpg.combat.ArcherBowService;
import com.disklexar.mmorpg.combat.CombatBuffService;
import com.disklexar.mmorpg.combat.CombatStateService;
import com.disklexar.mmorpg.combat.MmorpgScheduler;
@@ -25,6 +26,7 @@ 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.rpg.stats.CharacterStatService;
import com.disklexar.mmorpg.ui.AbilityBarService;
import com.hypixel.hytale.logger.HytaleLogger;
@@ -69,6 +71,7 @@ public final class Bootstrap {
PlayerSessionService sessionService = new PlayerSessionService(
plugin, config, accountRepository, characterRepository);
CharacterStatService characterStats = new CharacterStatService(sessionService);
CharacterInventoryService characterInventoryService =
new CharacterInventoryService(inventoryRepository);
CharacterService characterService = new CharacterService(
@@ -83,9 +86,11 @@ public final class Bootstrap {
GroupService groupService =
new GroupService(plugin, groupRepository, sessionService, progression);
AbilityService abilityService =
new AbilityService(scheduler, stunService, combatState, combatBuffs);
new AbilityService(scheduler, stunService, combatState, combatBuffs, sessionService, characterStats);
ArcherBowService archerBowService =
new ArcherBowService(sessionService, classService, combatState, combatBuffs);
PassiveEffectService passiveEffects =
new PassiveEffectService(plugin, scheduler, onlinePlayers, sessionService, combatState);
new PassiveEffectService(plugin, scheduler, onlinePlayers, sessionService, combatState, characterStats);
AbilityBarService abilityBarService = new AbilityBarService(
plugin, classService, abilityService, onlinePlayers, sessionService, progression, scheduler);
@@ -107,11 +112,13 @@ public final class Bootstrap {
registry.register(StunService.class, stunService);
registry.register(OnlinePlayerRegistry.class, onlinePlayers);
registry.register(PlayerSessionService.class, sessionService);
registry.register(CharacterStatService.class, characterStats);
registry.register(CharacterInventoryService.class, characterInventoryService);
registry.register(CharacterService.class, characterService);
registry.register(MmorpgScheduler.class, scheduler);
registry.register(GroupService.class, groupService);
registry.register(AbilityService.class, abilityService);
registry.register(ArcherBowService.class, archerBowService);
registry.register(AbilityBarService.class, abilityBarService);
registry.register(PassiveEffectService.class, passiveEffects);
@@ -1,8 +1,11 @@
package com.disklexar.mmorpg.combat;
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.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.entity.UUIDComponent;
@@ -13,6 +16,7 @@ import com.hypixel.hytale.server.core.modules.entity.component.TransformComponen
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.entitystats.EntityStatMap;
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;
@@ -61,8 +65,9 @@ public final class AbilityService {
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;
private static final double VOLLEY_HIT_RADIUS = 2.0;
private static final double VOLLEY_BACK_KNOCKBACK = 24.0;
private static final double VOLLEY_BACK_KNOCKBACK_Y = 6.0;
// --- Mage ---
private static final int FIREBALL_COUNT = 3;
@@ -70,6 +75,8 @@ public final class AbilityService {
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 PROJECTILE_TRACE_STEP = 0.5;
private static final double PROJECTILE_CYLINDER_HEIGHT = 2.5;
private static final double TELEPORT_DISTANCE = 8.0;
private static final double TORNADO_DISTANCE = 15.0;
private static final double TORNADO_RADIUS = 3.5;
@@ -124,6 +131,10 @@ public final class AbilityService {
private final StunService stunService;
private final CombatStateService combatState;
private final CombatBuffService combatBuffs;
@Nullable
private final PlayerSessionService sessions;
@Nullable
private final CharacterStatService characterStats;
private final Map<UUID, Map<String, Long>> cooldowns = new ConcurrentHashMap<>();
@@ -132,10 +143,22 @@ public final class AbilityService {
@Nonnull StunService stunService,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs) {
this(scheduler, stunService, combatState, combatBuffs, null, null);
}
public AbilityService(
@Nonnull MmorpgScheduler scheduler,
@Nonnull StunService stunService,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs,
@Nullable PlayerSessionService sessions,
@Nullable CharacterStatService characterStats) {
this.scheduler = scheduler;
this.stunService = stunService;
this.combatState = combatState;
this.combatBuffs = combatBuffs;
this.sessions = sessions;
this.characterStats = characterStats;
}
public long cooldownRemaining(@Nonnull UUID player, @Nonnull String abilityId) {
@@ -210,8 +233,15 @@ public final class AbilityService {
}
combatState.markCombat(caster);
long cooldownMillis = ability.cooldownMillis();
if (sessions != null && characterStats != null) {
PlayerProfile profile = sessions.getSession(caster);
if (profile != null) {
cooldownMillis = characterStats.cooldownMillis(profile, cooldownMillis);
}
}
cooldowns.computeIfAbsent(caster, k -> new ConcurrentHashMap<>())
.put(ability.id(), System.currentTimeMillis() + ability.cooldownMillis());
.put(ability.id(), System.currentTimeMillis() + cooldownMillis);
return CastResult.OK;
}
@@ -403,11 +433,6 @@ public final class AbilityService {
@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(
@@ -419,13 +444,13 @@ public final class AbilityService {
// 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);
Vector3d launch = new Vector3d(position(store, casterRef)).add(0, 1.0, 0);
CombatFx.sound(store, SFX_BOW, launch, 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);
fireProjectile(store, casterRef, launch, dir, VOLLEY_RANGE, VOLLEY_HIT_RADIUS,
VOLLEY_ARROW_DAMAGE, VFX_ARROW_TRAIL, VFX_ARROW_HIT, DamageCause.PROJECTILE);
}
});
}
@@ -436,11 +461,6 @@ public final class AbilityService {
@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);
});
}
// ------------------------------------------------------------------------------------------
@@ -459,7 +479,7 @@ public final class AbilityService {
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);
FIREBALL_DAMAGE, VFX_FIRE_TRAIL, VFX_FIRE_HIT, DamageCause.PROJECTILE);
}
});
}
@@ -513,7 +533,7 @@ public final class AbilityService {
}
CombatFx.knockback(store, target, new Vector3d(
pull.x * TORNADO_PULL, 2.5, pull.z * TORNADO_PULL));
dealDamage(store, casterRef, target, TORNADO_DAMAGE);
dealDamage(store, casterRef, target, TORNADO_DAMAGE, DamageCause.PROJECTILE);
CombatFx.vfx(store, VFX_TORNADO_HIT, tpos);
}
}), 0L, TORNADO_PERIOD);
@@ -631,23 +651,49 @@ public final class AbilityService {
float damage,
@Nonnull String trailVfx,
@Nonnull String hitVfx) {
fireProjectile(store, casterRef, origin, dir, range, hitRadius, damage, trailVfx, hitVfx, DamageCause.PHYSICAL);
}
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,
@Nonnull DamageCause cause) {
Vector3d dirNorm = new Vector3d(dir);
if (dirNorm.lengthSquared() < 1.0e-6) {
return;
}
dirNorm.normalize();
Vector3d point = new Vector3d(origin);
Vector3d step = new Vector3d(dir).mul(1.0);
int steps = (int) Math.ceil(range);
Vector3d step = new Vector3d(dirNorm).mul(PROJECTILE_TRACE_STEP);
int steps = Math.max(1, (int) Math.ceil(range / PROJECTILE_TRACE_STEP));
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)) {
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInCylinder(
point, hitRadius, PROJECTILE_CYLINDER_HEIGHT, store)) {
if (target.equals(casterRef) || !isDamageable(store, target)) {
continue;
}
dealDamage(store, casterRef, target, damage);
dealDamage(store, casterRef, target, damage, cause);
CombatFx.vfx(store, hitVfx, targetPosition(store, target, point));
return;
}
}
}
private static boolean isDamageable(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
return store.getComponent(ref, EntityStatMap.getComponentType()) != null;
}
@Nonnull
private Vector3d rotatedHorizontal(@Nonnull Vector3d base, double degrees) {
Vector3d v = new Vector3d(base);
@@ -695,7 +741,16 @@ public final class AbilityService {
@Nonnull Ref<EntityStore> casterRef,
@Nonnull Ref<EntityStore> target,
float amount) {
Damage damage = new Damage(new Damage.EntitySource(casterRef), DamageCause.PHYSICAL, amount);
dealDamage(store, casterRef, target, amount, DamageCause.PHYSICAL);
}
private void dealDamage(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull Ref<EntityStore> target,
float amount,
@Nonnull DamageCause cause) {
Damage damage = new Damage(new Damage.EntitySource(casterRef), cause, amount);
DamageSystems.executeDamage(target, store, damage);
}
@@ -0,0 +1,160 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.entity.InteractionContext;
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
import com.hypixel.hytale.server.core.inventory.ItemStack;
import com.hypixel.hytale.server.core.inventory.container.ItemContainer;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.RootInteraction;
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.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Archer bow: one arrow in inventory is enough to shoot forever.
* Each left click fires instantly at max charge, with a 1s cooldown between shots.
*/
public final class ArcherBowService {
public enum ShootResult {
OK, ON_COOLDOWN, NO_ARROW, NOT_BOW, PROJECTILE_UNAVAILABLE
}
private static final String ARCHER_PROJECTILE_ROOT = "Root_Mmorpg_Archer_Projectile";
private static final long SHOT_COOLDOWN_MS = 1_000L;
/** Tir rapide: x4 cadence de tir (cooldown ÷ 4, ~4 tirs/s). */
private static final double RAPID_FIRE_SPEED_FACTOR = 4.0;
private final PlayerSessionService sessions;
private final ClassService classService;
private final CombatStateService combatState;
private final CombatBuffService combatBuffs;
private final Map<UUID, Long> nextShotAt = new ConcurrentHashMap<>();
public ArcherBowService(
@Nonnull PlayerSessionService sessions,
@Nonnull ClassService classService,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs) {
this.sessions = sessions;
this.classService = classService;
this.combatState = combatState;
this.combatBuffs = combatBuffs;
}
public boolean isArcher(@Nonnull PlayerRef playerRef) {
PlayerProfile profile = sessions.getSession(playerRef.getUuid());
if (profile == null) {
return false;
}
return ClassCatalog.ARCHER.id().equals(profile.getClassId());
}
@Nonnull
public ShootResult tryShoot(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull InteractionContext interactionContext) {
if (!isBowInHand(store, ref)) {
return ShootResult.NOT_BOW;
}
if (!hasArrow(store, ref)) {
playerRef.sendMessage(com.hypixel.hytale.server.core.Message.raw(
"Vous avez besoin d'au moins une flèche dans votre inventaire."));
return ShootResult.NO_ARROW;
}
UUID uuid = playerRef.getUuid();
long now = System.currentTimeMillis();
Long readyAt = nextShotAt.get(uuid);
if (readyAt != null && now < readyAt) {
return ShootResult.ON_COOLDOWN;
}
RootInteraction projectileRoot = RootInteraction.getAssetMap().getAsset(ARCHER_PROJECTILE_ROOT);
if (projectileRoot == null) {
return ShootResult.PROJECTILE_UNAVAILABLE;
}
nextShotAt.put(uuid, now + shotCooldownMs(uuid));
interactionContext.execute(projectileRoot);
combatState.markCombat(uuid);
return ShootResult.OK;
}
public void clear(@Nonnull UUID playerUuid) {
nextShotAt.remove(playerUuid);
}
private long shotCooldownMs(@Nonnull UUID playerUuid) {
if (combatBuffs.hasRapidFire(playerUuid)) {
return Math.max(200L, Math.round(SHOT_COOLDOWN_MS / RAPID_FIRE_SPEED_FACTOR));
}
return SHOT_COOLDOWN_MS;
}
private static boolean isBowItemId(@Nonnull String itemId) {
String lower = itemId.toLowerCase(Locale.ROOT);
return lower.contains("bow") && !lower.contains("crossbow");
}
private static boolean isBowInHand(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
try {
ItemStack held = InventoryComponent.getItemInHand(store, ref);
if (ItemStack.isEmpty(held)) {
return false;
}
String itemId = held.getItemId();
return itemId != null && isBowItemId(itemId);
} catch (Throwable t) {
return false;
}
}
private static boolean hasArrow(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
return countArrows(store, ref) > 0;
}
private static int countArrows(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
int total = 0;
total += countArrowsInSection(store, ref, InventoryComponent.HOTBAR_SECTION_ID);
total += countArrowsInSection(store, ref, InventoryComponent.STORAGE_SECTION_ID);
total += countArrowsInSection(store, ref, InventoryComponent.UTILITY_SECTION_ID);
return total;
}
private static int countArrowsInSection(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
int sectionId) {
ItemContainer container = InventoryUtils.getSectionById(ref, sectionId, store);
if (container == null) {
return 0;
}
int count = 0;
for (short slot = 0; slot < container.getCapacity(); slot++) {
ItemStack stack = container.getItemStack(slot);
if (ItemStack.isEmpty(stack)) {
continue;
}
String itemId = stack.getItemId();
if (itemId != null && itemId.toLowerCase(Locale.ROOT).contains("arrow")) {
count += stack.getQuantity();
}
}
return count;
}
}
@@ -10,8 +10,7 @@ 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 rapide": x4 bow shot rate (250 ms between clicks),</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>
@@ -2,11 +2,13 @@ package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.core.service.Service;
import com.disklexar.mmorpg.player.CharacterService;
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.disklexar.mmorpg.rpg.stats.CharacterStatService;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.MovementSettings;
@@ -29,13 +31,19 @@ import java.util.concurrent.ScheduledFuture;
public final class PassiveEffectService implements Service {
private static final long RECONCILE_PERIOD = 2_000L;
private static final long INVENTORY_SAVE_PERIOD = 15_000L;
private final MmorpgScheduler scheduler;
private final OnlinePlayerRegistry online;
private final PlayerSessionService sessions;
private final CombatStateService combatState;
@Nullable
private final CharacterStatService characterStats;
@Nullable
private final CharacterService characters;
private ScheduledFuture<?> task;
private ScheduledFuture<?> inventoryTask;
public PassiveEffectService(
@Nonnull MmorpgPlugin plugin,
@@ -43,15 +51,44 @@ public final class PassiveEffectService implements Service {
@Nonnull OnlinePlayerRegistry online,
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState) {
this(plugin, scheduler, online, sessions, combatState, null, null);
}
public PassiveEffectService(
@Nonnull MmorpgPlugin plugin,
@Nonnull MmorpgScheduler scheduler,
@Nonnull OnlinePlayerRegistry online,
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState,
@Nullable CharacterStatService characterStats) {
this(plugin, scheduler, online, sessions, combatState, characterStats, null);
}
public PassiveEffectService(
@Nonnull MmorpgPlugin plugin,
@Nonnull MmorpgScheduler scheduler,
@Nonnull OnlinePlayerRegistry online,
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState,
@Nullable CharacterStatService characterStats,
@Nullable CharacterService characters) {
this.scheduler = scheduler;
this.online = online;
this.sessions = sessions;
this.combatState = combatState;
this.characterStats = characterStats;
this.characters = characters;
}
@Override
public void start() {
task = scheduler.runRepeating(this::reconcileAll, RECONCILE_PERIOD, RECONCILE_PERIOD);
if (characters != null) {
inventoryTask = scheduler.runRepeating(
() -> characters.persistAllOnlineInventories(online),
INVENTORY_SAVE_PERIOD,
INVENTORY_SAVE_PERIOD);
}
}
@Override
@@ -60,6 +97,10 @@ public final class PassiveEffectService implements Service {
task.cancel(false);
task = null;
}
if (inventoryTask != null) {
inventoryTask.cancel(false);
inventoryTask = null;
}
}
private void reconcileAll() {
@@ -79,6 +120,10 @@ public final class PassiveEffectService implements Service {
if (ref == null) {
return;
}
PlayerProfile profile = sessions.getSession(player.uuid());
if (profile == null) {
return;
}
try {
Store<EntityStore> store = player.world().getEntityStore().getStore();
MovementManager movement = store.getComponent(ref, MovementManager.getComponentType());
@@ -91,7 +136,10 @@ public final class PassiveEffectService implements Service {
return;
}
float baseline = defaults.baseSpeed;
float target = buffed ? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS) : baseline;
float speedBonus = characterStats == null ? 0f : characterStats.movementSpeedBonus(profile);
float target = (buffed
? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS)
: baseline) + speedBonus;
if (Math.abs(current.baseSpeed - target) > 0.0001f) {
current.baseSpeed = target;
movement.update(player.playerRef().getPacketHandler());
@@ -0,0 +1,82 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import com.hypixel.hytale.component.ArchetypeChunk;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.ComponentType;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.dependency.Dependency;
import com.hypixel.hytale.component.dependency.Order;
import com.hypixel.hytale.component.dependency.SystemDependency;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.component.system.tick.EntityTickingSystem;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.server.core.entity.InteractionManager;
import com.hypixel.hytale.server.core.modules.interaction.InteractionModule;
import com.hypixel.hytale.server.core.modules.interaction.system.InteractionSystems;
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.Set;
/**
* Applies dexterity as real attack-speed acceleration on weapon interactions (Primary / Secondary).
* Runs before the vanilla interaction tick so {@link InteractionManager#setGlobalTimeShift} is picked up.
*/
public final class MmorpgAttackSpeedSystem extends EntityTickingSystem<EntityStore> {
private final PlayerSessionService sessions;
private final CharacterStatService characterStats;
private final ComponentType<EntityStore, InteractionManager> interactionManagerType;
public MmorpgAttackSpeedSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull CharacterStatService characterStats) {
this.sessions = sessions;
this.characterStats = characterStats;
this.interactionManagerType = InteractionModule.get().getInteractionManagerComponent();
}
@Override
public Query<EntityStore> getQuery() {
return Query.and(interactionManagerType, PlayerRef.getComponentType());
}
@Override
public Set<Dependency<EntityStore>> getDependencies() {
return Set.of(new SystemDependency<>(
Order.BEFORE,
InteractionSystems.TickInteractionManagerSystem.class));
}
@Override
public void tick(
float dt,
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
PlayerRef playerRef = chunk.getComponent(index, PlayerRef.getComponentType());
if (playerRef == null) {
return;
}
PlayerProfile profile = sessions.getSession(playerRef.getUuid());
if (profile == null) {
return;
}
float shift = characterStats.attackSpeedTimeShift(profile);
if (shift <= 0f) {
return;
}
InteractionManager manager = chunk.getComponent(index, interactionManagerType);
if (manager == null) {
return;
}
manager.setGlobalTimeShift(InteractionType.Primary, shift);
manager.setGlobalTimeShift(InteractionType.Secondary, shift);
}
}
@@ -7,7 +7,9 @@ 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.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
import com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import com.hypixel.hytale.component.ArchetypeChunk;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
@@ -15,6 +17,8 @@ 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.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.ItemStack;
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;
@@ -23,6 +27,8 @@ import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Locale;
import java.util.UUID;
/**
@@ -37,8 +43,6 @@ 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.
@@ -51,6 +55,8 @@ public final class MmorpgDamageSystem extends DamageEventSystem {
private final CombatBuffService combatBuffs;
private final PoisonService poisonService;
private final OnlinePlayerRegistry onlinePlayers;
@Nullable
private final CharacterStatService characterStats;
public MmorpgDamageSystem(
@Nonnull PlayerSessionService sessions,
@@ -58,11 +64,22 @@ public final class MmorpgDamageSystem extends DamageEventSystem {
@Nonnull CombatBuffService combatBuffs,
@Nonnull PoisonService poisonService,
@Nonnull OnlinePlayerRegistry onlinePlayers) {
this(sessions, combatState, combatBuffs, poisonService, onlinePlayers, null);
}
public MmorpgDamageSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs,
@Nonnull PoisonService poisonService,
@Nonnull OnlinePlayerRegistry onlinePlayers,
@Nullable CharacterStatService characterStats) {
this.sessions = sessions;
this.combatState = combatState;
this.combatBuffs = combatBuffs;
this.poisonService = poisonService;
this.onlinePlayers = onlinePlayers;
this.characterStats = characterStats;
}
@Override
@@ -72,7 +89,8 @@ public final class MmorpgDamageSystem extends DamageEventSystem {
@Override
public SystemGroup<EntityStore> getGroup() {
return DamageModule.get().getInspectDamageGroup();
// gather → filter → apply : only the filter stage mutates damage before it hits health.
return DamageModule.get().getFilterDamageGroup();
}
@Override
@@ -93,9 +111,8 @@ public final class MmorpgDamageSystem extends DamageEventSystem {
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();
UUID attackerUuid = playerUuid(store, attackerRef);
if (attackerUuid != null) {
combatState.markCombat(attackerUuid);
PlayerProfile profile = sessions.getSession(attackerUuid);
if (profile != null
@@ -104,12 +121,12 @@ public final class MmorpgDamageSystem extends DamageEventSystem {
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;
boolean archerBowHit = isArcherBowHit(profile, store, attackerRef, damage.getCause());
// Vanilla melee uses Bludgeoning / Slashing (children of Physical), not the
// PHYSICAL constant itself. Class ability DoT uses Elemental / Environment.
if (isMeleePhysicalDamage(damage.getCause()) || archerBowHit) {
if (profile != null && characterStats != null) {
amount += characterStats.attackBonus(profile);
}
if (combatBuffs.consumePowerShot(attackerUuid)) {
amount *= POWER_SHOT_FACTOR;
@@ -136,8 +153,77 @@ public final class MmorpgDamageSystem extends DamageEventSystem {
}
}
private UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
@Nullable
private UUID playerUuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef != null) {
return playerRef.getUuid();
}
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
@Nullable
private UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
return playerUuid(store, ref);
}
private static boolean isArcherBowHit(
@Nullable PlayerProfile profile,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> attackerRef,
@Nullable DamageCause cause) {
if (profile == null || !ClassCatalog.ARCHER.id().equals(profile.getClassId())) {
return false;
}
if (!isProjectileDamage(cause)) {
return false;
}
try {
ItemStack held = InventoryComponent.getItemInHand(store, attackerRef);
if (ItemStack.isEmpty(held)) {
return false;
}
String itemId = held.getItemId();
if (itemId == null) {
return false;
}
String lower = itemId.toLowerCase(Locale.ROOT);
return lower.contains("bow") && !lower.contains("crossbow");
} catch (Throwable t) {
return false;
}
}
private static boolean isProjectileDamage(@Nullable DamageCause cause) {
if (cause == null) {
return false;
}
String id = cause.getId();
return id != null && id.equalsIgnoreCase("Projectile");
}
/**
* True for fists, swords, axes, etc. Excludes projectiles and elemental ability ticks.
*/
private static boolean isMeleePhysicalDamage(@Nullable DamageCause cause) {
if (cause == null) {
return false;
}
if (cause == DamageCause.PHYSICAL) {
return true;
}
String id = cause.getId();
if (id != null) {
String lower = id.toLowerCase(Locale.ROOT);
if (lower.equals("projectile") || lower.equals("elemental") || lower.equals("environment")) {
return false;
}
if (lower.equals("bludgeoning") || lower.equals("slashing") || lower.equals("physical")) {
return true;
}
}
String inherits = cause.getInherits();
return inherits != null && inherits.equalsIgnoreCase("Physical");
}
}
@@ -0,0 +1,63 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.player.CharacterService;
import com.disklexar.mmorpg.player.PlayerSessionService;
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.entity.entities.Player;
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;
/**
* Persists permanent character death when {@link DeathComponent} is added.
*/
public final class MmorpgPlayerDeathSystem extends DeathSystems.OnDeathSystem {
private static final Query<EntityStore> QUERY = Player.getComponentType();
private final PlayerSessionService sessions;
private final CharacterService characters;
public MmorpgPlayerDeathSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull CharacterService characters) {
this.sessions = sessions;
this.characters = characters;
}
@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) {
PlayerRef playerRef = store.getComponent(deadRef, PlayerRef.getComponentType());
if (playerRef == null) {
return;
}
UUID accountUuid = playerRef.getUuid();
if (sessions.getSession(accountUuid) == null) {
return;
}
Player player = store.getComponent(deadRef, Player.getComponentType());
if (player == null) {
return;
}
characters.retireActiveCharacterOnDeath(playerRef, deadRef, store, accountUuid);
}
}
@@ -0,0 +1,60 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.combat.MmorpgScheduler;
import com.disklexar.mmorpg.player.CharacterService;
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.entity.entities.Player;
import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.RespawnSystems;
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;
/**
* Opens the character selector after the vanilla respawn flow clears the death screen.
*/
public final class MmorpgPlayerRespawnSystem extends RespawnSystems.OnRespawnSystem {
private static final Query<EntityStore> QUERY = Player.getComponentType();
private final CharacterService characters;
private final MmorpgScheduler scheduler;
public MmorpgPlayerRespawnSystem(
@Nonnull CharacterService characters,
@Nonnull MmorpgScheduler scheduler) {
this.characters = characters;
this.scheduler = scheduler;
}
@Override
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public void onComponentRemoved(
@Nonnull Ref<EntityStore> ref,
@Nonnull DeathComponent death,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef == null) {
return;
}
UUID accountUuid = playerRef.getUuid();
Player player = store.getComponent(ref, Player.getComponentType());
if (player == null) {
return;
}
scheduler.runLater(() -> player.getWorld().execute(() ->
characters.openCharacterSelectorAfterRespawn(playerRef, ref, store, accountUuid)), 500L);
}
}
@@ -1,13 +1,13 @@
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.player.CharacterService;
import com.disklexar.mmorpg.rpg.clazz.ClassSelectionSupport;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
@@ -31,6 +31,7 @@ public final class ClassCommand extends AbstractCommandCollection {
public ClassCommand(@Nonnull MmorpgPlugin plugin) {
super("class", "Gérer votre classe");
addSubCommand(new Choose(plugin));
addSubCommand(new Selector(plugin));
addSubCommand(new Leave(plugin));
addSubCommand(new Info(plugin));
}
@@ -54,17 +55,56 @@ public final class ClassCommand extends AbstractCommandCollection {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
boolean firstClass = !classService.hasClass(profile);
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);
PlayerClass chosen = ClassSelectionSupport.apply(plugin, store, ref, playerRef, profile, id);
if (chosen != null) {
context.sendMessage(Message.raw("Classe choisie : " + chosen.displayName()));
if (firstClass) {
CharacterService characters = CommandSupport.service(plugin, CharacterService.class);
if (characters != null) {
characters.welcomeAfterFirstClassChoice(playerRef, profile);
}
}
} else {
context.sendMessage(Message.raw("Classe inconnue. Disponibles : " + classList()));
}
}
}
private static final class Selector extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Selector(@Nonnull MmorpgPlugin plugin) {
super("selecteur", "Ouvrir le sélecteur de 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);
CharacterService characters = CommandSupport.service(plugin, CharacterService.class);
if (profile == null || classService == null || characters == null) {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
world.execute(() -> characters.openClassSelector(
playerRef,
ref,
store,
profile,
!classService.hasClass(profile),
false));
}
}
private static final class Leave extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
@@ -123,21 +163,6 @@ public final class ClassCommand extends AbstractCommandCollection {
}
}
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) {
@@ -1,11 +1,11 @@
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.combat.AbilityService;
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.ui.AbilityBarService;
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;
@@ -39,9 +39,8 @@ public final class HudCommand extends AbstractPlayerCommand {
}
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) {
AbilityService abilities = CommandSupport.service(plugin, AbilityService.class);
if (classService == null || abilities == null) {
context.sendMessage(Message.raw("Service HUD indisponible."));
return;
}
@@ -51,14 +50,19 @@ public final class HudCommand extends AbstractPlayerCommand {
return;
}
OnlinePlayer online = onlinePlayers.get(playerRef.getUuid());
if (online == null) {
context.sendMessage(Message.raw("Joueur non enregistré. Reconnectez-vous."));
PlayerClass playerClass = classService.getClass(profile);
if (playerClass == null) {
context.sendMessage(Message.raw("Classe introuvable."));
return;
}
abilityBar.dismiss(online);
abilityBar.sync(online, profile);
context.sendMessage(Message.raw("Barre de capacités renvoyée (colonne gauche)."));
context.sendMessage(Message.raw("=== Capacités (" + playerClass.displayName() + ") ==="));
for (AbilityDefinition ability : playerClass.abilities()) {
long remaining = abilities.cooldownRemaining(profile.getUuid(), ability.id());
String cooldown = remaining > 0 ? "" + (remaining / 1000 + 1) + "s" : " — prête";
context.sendMessage(Message.raw(
ability.slot() + ". " + ability.displayName() + cooldown));
}
context.sendMessage(Message.raw("Utilisez /mmorpg ability <1|2|3> ou les touches Use ability en jeu."));
}
}
@@ -5,6 +5,7 @@ import com.disklexar.mmorpg.player.PlayerInfoView;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.combat.AbilityService;
import com.disklexar.mmorpg.progression.ProgressionService;
import com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import com.disklexar.mmorpg.ui.PlayerMenuPage;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
@@ -28,7 +29,7 @@ public final class MenuCommand extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
public MenuCommand(@Nonnull MmorpgPlugin plugin) {
super("menu", "Ouvrir le menu joueur MMORPG (Personnage / Inventaire / Compétences)");
super("menu", "Ouvrir le menu joueur MMORPG (Personnage / Inventaire / Statistiques / Compétences)");
this.plugin = plugin;
}
@@ -53,6 +54,7 @@ public final class MenuCommand extends AbstractPlayerCommand {
profile,
CommandSupport.service(plugin, ProgressionService.class),
CommandSupport.service(plugin, AbilityService.class),
CommandSupport.service(plugin, CharacterStatService.class),
PlayerMenuPage.Tab.CHARACTER));
} catch (Throwable t) {
playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
@@ -25,10 +25,10 @@ public final class MmorpgHelpCommand extends AbstractAsyncCommand {
/mmorpg help — cette aide
/mmorpg profile — résumé court de votre profil
/mmorpg info — toutes vos informations (chat)
/mmorpg menu — menu joueur (Personnage / Inventaire / Compétences)
/mmorpg menu — menu joueur (Personnage / Inventaire / Statistiques / Compétences)
/mmorpg inventory — menu joueur, onglet Inventaire
/mmorpg menucustom — interface personnalisée (asset pack, test)
/mmorpg class <choisir|quitter|info> [classe]
/mmorpg class <choisir|selecteur|quitter|info> [classe]
/mmorpg power <recevoir|retirer|info> [pouvoir]
/mmorpg job <rejoindre|quitter|info> [metier]
/mmorpg group <inviter|accepter|quitter|kick|info> [joueur]
@@ -2,13 +2,12 @@ package com.disklexar.mmorpg.events;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.bootstrap.Bootstrap;
import com.disklexar.mmorpg.combat.ArcherBowService;
import com.disklexar.mmorpg.combat.MmorpgScheduler;
import com.disklexar.mmorpg.player.CharacterService;
import com.disklexar.mmorpg.player.OnlinePlayer;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.ui.AbilityBarService;
import com.disklexar.mmorpg.ui.CharacterSelectPage;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.logger.HytaleLogger;
@@ -61,8 +60,8 @@ public final class PlayerConnectionHandler {
InventoryUtils.clear(entityRef, store);
if (universePlayerRef != null) {
scheduler.runLater(() -> openCharacterSelector(
player, entityRef, store, universePlayerRef, characters, uuid), 500L);
scheduler.runLater(() -> player.getWorld().execute(() ->
characters.handleJoinSelection(universePlayerRef, entityRef, store, uuid)), 2_500L);
}
} catch (SQLException e) {
logger.at(Level.SEVERE).log(
@@ -86,49 +85,23 @@ public final class PlayerConnectionHandler {
UUID accountUuid = playerRef.getUuid();
OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class);
AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class);
ArcherBowService archerBow = bootstrap.getRegistry().require(ArcherBowService.class);
CharacterService characters = bootstrap.getRegistry().require(CharacterService.class);
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
OnlinePlayer online = onlinePlayers.get(accountUuid);
PlayerSessionService.AccountSession session = sessions.getAccountSession(accountUuid);
Ref<EntityStore> entityRef = playerRef.getReference();
if (session != null
&& session.activeCharacter() != null
&& online != null
&& entityRef != null
&& entityRef.isValid()) {
Store<EntityStore> store = online.world().getEntityStore().getStore();
characters.persistActiveCharacter(session, store, entityRef);
if (session != null && session.activeCharacter() != null) {
// Use PlayerRef (entity ref or holder) so inventory is saved even when the entity
// is already torn down at disconnect time.
characters.persistActiveCharacter(session, playerRef);
}
onlinePlayers.remove(accountUuid);
abilityBar.dismiss(accountUuid);
archerBow.clear(accountUuid);
sessions.unload(accountUuid);
}
private void openCharacterSelector(
@Nonnull Player player,
@Nonnull Ref<EntityStore> entityRef,
@Nonnull Store<EntityStore> store,
@Nonnull PlayerRef playerRef,
@Nonnull CharacterService characters,
@Nonnull UUID accountUuid) {
player.getWorld().execute(() -> {
try {
player.getPageManager().openCustomPage(entityRef, store, new CharacterSelectPage(
playerRef,
accountUuid,
characters,
plugin.getBootstrap().getConfig().getMaxCharactersPerAccount()));
} catch (Throwable t) {
logger.at(Level.WARNING).log(
"Failed to open character selector for %s: %s",
playerRef.getUsername(),
t.getMessage());
}
});
}
@Nonnull
private UUID resolveUuid(
@Nonnull Store<EntityStore> store,
@@ -0,0 +1,98 @@
package com.disklexar.mmorpg.interaction;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.combat.ArcherBowService;
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.modules.interaction.interaction.CooldownHandler;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.RootInteraction;
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.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Replaces vanilla bow Primary for archers: one click = instant max-power shot, 1s cooldown between clicks.
*/
public final class MmorpgArcherShootInteraction extends SimpleInstantInteraction {
public static final String ID = "mmorpg_archer_shoot";
private static final String VANILLA_PRIMARY = "Root_Weapon_Shortbow_Primary_Shoot";
public static final BuilderCodec<MmorpgArcherShootInteraction> CODEC = BuilderCodec.builder(
MmorpgArcherShootInteraction.class,
MmorpgArcherShootInteraction::new,
SimpleInstantInteraction.CODEC)
.build();
public MmorpgArcherShootInteraction() {
}
public MmorpgArcherShootInteraction(@Nonnull String id) {
super(id);
}
@Override
protected void firstRun(
@Nonnull InteractionType interactionType,
@Nonnull InteractionContext interactionContext,
@Nonnull CooldownHandler cooldownHandler) {
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());
if (playerRef == null) {
interactionContext.getState().state = InteractionState.Failed;
return;
}
ArcherBowService archerBow = resolveService();
if (archerBow == null) {
interactionContext.getState().state = InteractionState.Failed;
return;
}
if (!archerBow.isArcher(playerRef)) {
runVanillaPrimary(interactionContext);
return;
}
ArcherBowService.ShootResult result = archerBow.tryShoot(
playerRef,
entityRef,
store,
interactionContext);
interactionContext.getState().state = switch (result) {
case OK, ON_COOLDOWN -> InteractionState.Finished;
case NO_ARROW, NOT_BOW, PROJECTILE_UNAVAILABLE -> InteractionState.Failed;
};
}
private void runVanillaPrimary(@Nonnull InteractionContext interactionContext) {
RootInteraction vanilla = RootInteraction.getAssetMap().getAsset(VANILLA_PRIMARY);
if (vanilla != null) {
interactionContext.execute(vanilla);
}
interactionContext.getState().state = InteractionState.Finished;
}
@Nullable
private static ArcherBowService resolveService() {
MmorpgPlugin plugin = MmorpgPlugin.get();
if (plugin == null || plugin.getBootstrap() == null) {
return null;
}
return plugin.getBootstrap().getRegistry().get(ArcherBowService.class);
}
}
@@ -2,6 +2,7 @@ package com.disklexar.mmorpg.persistence.repository;
import com.disklexar.mmorpg.persistence.DatabaseManager;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.stats.CharacterStat;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
@@ -42,8 +43,15 @@ public final class CharacterRepository {
return findByQuery("WHERE account_uuid = ? ORDER BY date_creation ASC", accountUuid.toString());
}
@Nonnull
public List<PlayerProfile> findAliveByAccountUuid(@Nonnull UUID accountUuid) throws SQLException {
return findByQuery(
"WHERE account_uuid = ? AND death_time IS NULL ORDER BY date_creation ASC",
accountUuid.toString());
}
public int countByAccount(@Nonnull UUID accountUuid) throws SQLException {
String sql = "SELECT COUNT(*) FROM characters WHERE account_uuid = ?";
String sql = "SELECT COUNT(*) FROM characters WHERE account_uuid = ? AND death_time IS NULL";
try (PreparedStatement statement = connection().prepareStatement(sql)) {
statement.setString(1, accountUuid.toString());
try (ResultSet resultSet = statement.executeQuery()) {
@@ -72,8 +80,10 @@ public final class CharacterRepository {
String sql = """
INSERT INTO characters (
id, account_uuid, name, level, experience, class_id, powers, jobs,
guild_id, group_id, money, race_id, date_creation, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
guild_id, group_id, money, race_id, date_creation, updated_at,
death_time, total_time_play, stat_points, stat_force, stat_vitality,
stat_dexterity, stat_speed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
level = excluded.level,
@@ -85,7 +95,14 @@ public final class CharacterRepository {
group_id = excluded.group_id,
money = excluded.money,
race_id = excluded.race_id,
updated_at = excluded.updated_at
updated_at = excluded.updated_at,
death_time = excluded.death_time,
total_time_play = excluded.total_time_play,
stat_points = excluded.stat_points,
stat_force = excluded.stat_force,
stat_vitality = excluded.stat_vitality,
stat_dexterity = excluded.stat_dexterity,
stat_speed = excluded.stat_speed
""";
try (PreparedStatement statement = connection().prepareStatement(sql)) {
@@ -103,6 +120,17 @@ public final class CharacterRepository {
statement.setString(12, profile.getRaceId());
statement.setLong(13, profile.getDateCreation());
statement.setLong(14, profile.getUpdatedAt());
if (profile.getDeathTime() != null) {
statement.setLong(15, profile.getDeathTime());
} else {
statement.setNull(15, java.sql.Types.INTEGER);
}
statement.setLong(16, profile.getTotalTimePlay());
statement.setInt(17, profile.getStatPoints());
statement.setInt(18, profile.getStat(CharacterStat.FORCE));
statement.setInt(19, profile.getStat(CharacterStat.VITALITY));
statement.setInt(20, profile.getStat(CharacterStat.DEXTERITY));
statement.setInt(21, profile.getStat(CharacterStat.SPEED));
statement.executeUpdate();
}
@@ -123,7 +151,9 @@ public final class CharacterRepository {
throws SQLException {
String sql = """
SELECT id, account_uuid, name, level, experience, class_id, powers, jobs,
guild_id, group_id, money, race_id, date_creation, updated_at
guild_id, group_id, money, race_id, date_creation, updated_at,
death_time, total_time_play, stat_points, stat_force, stat_vitality,
stat_dexterity, stat_speed
FROM characters
""" + whereClause;
@@ -159,6 +189,16 @@ public final class CharacterRepository {
String raceId = resultSet.getString("race_id");
profile.setRaceId(raceId != null ? raceId : "human");
profile.setUpdatedAt(resultSet.getLong("updated_at"));
long deathTime = resultSet.getLong("death_time");
if (!resultSet.wasNull()) {
profile.setDeathTime(deathTime);
}
profile.setTotalTimePlay(resultSet.getLong("total_time_play"));
profile.setStatPoints(resultSet.getInt("stat_points"));
profile.setStat(CharacterStat.FORCE, resultSet.getInt("stat_force"));
profile.setStat(CharacterStat.VITALITY, resultSet.getInt("stat_vitality"));
profile.setStat(CharacterStat.DEXTERITY, resultSet.getInt("stat_dexterity"));
profile.setStat(CharacterStat.SPEED, resultSet.getInt("stat_speed"));
return profile;
}
@@ -27,7 +27,10 @@ public final class SchemaInitializer {
"001_init.sql",
"002_rpg.sql",
"003_web_password.sql",
"004_characters.sql");
"004_characters.sql",
"005_character_death_playtime.sql",
"006_character_stats.sql",
"007_trim_character_stats.sql");
private SchemaInitializer() {
}
@@ -2,6 +2,7 @@ package com.disklexar.mmorpg.player;
import com.disklexar.mmorpg.persistence.repository.CharacterInventoryRepository;
import com.disklexar.mmorpg.persistence.repository.CharacterInventoryRepository.InventorySlotRecord;
import com.hypixel.hytale.component.Holder;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
@@ -9,6 +10,7 @@ import com.hypixel.hytale.server.core.inventory.InventoryUtils;
import com.hypixel.hytale.server.core.inventory.ItemStack;
import com.hypixel.hytale.server.core.inventory.container.ItemContainer;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import org.bson.BsonDocument;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -26,7 +28,9 @@ public final class CharacterInventoryService {
InventoryComponent.STORAGE_SECTION_ID,
InventoryComponent.HOTBAR_SECTION_ID,
InventoryComponent.ARMOR_SECTION_ID,
InventoryComponent.UTILITY_SECTION_ID
InventoryComponent.UTILITY_SECTION_ID,
InventoryComponent.TOOLS_SECTION_ID,
InventoryComponent.BACKPACK_SECTION_ID,
};
private final CharacterInventoryRepository repository;
@@ -42,6 +46,12 @@ public final class CharacterInventoryService {
repository.replaceAll(characterId, captureFromPlayer(store, ref));
}
public void saveFromHolder(
@Nonnull UUID characterId,
@Nonnull Holder<EntityStore> holder) throws SQLException {
repository.replaceAll(characterId, captureFromHolder(holder));
}
public void loadToPlayer(
@Nonnull UUID characterId,
@Nonnull Store<EntityStore> store,
@@ -55,34 +65,54 @@ public final class CharacterInventoryService {
@Nonnull Ref<EntityStore> ref) {
List<InventorySlotRecord> slots = new ArrayList<>();
for (int sectionId : SECTION_IDS) {
ItemContainer container = sectionContainer(store, ref, sectionId);
if (container == null) {
continue;
}
for (short slot = 0; slot < container.getCapacity(); slot++) {
ItemStack stack = container.getItemStack(slot);
if (ItemStack.isEmpty(stack)) {
continue;
}
String itemId = stack.getItemId();
if (itemId == null || itemId.isBlank()) {
continue;
}
Double durability = stack.getMaxDurability() > 0 ? stack.getDurability() : null;
Double maxDurability = stack.getMaxDurability() > 0 ? stack.getMaxDurability() : null;
slots.add(new InventorySlotRecord(
sectionId,
slot,
itemId,
stack.getQuantity(),
durability,
maxDurability,
null));
}
captureSection(slots, sectionId, sectionContainer(store, ref, sectionId));
}
return slots;
}
@Nonnull
public List<InventorySlotRecord> captureFromHolder(@Nonnull Holder<EntityStore> holder) {
List<InventorySlotRecord> slots = new ArrayList<>();
for (int sectionId : SECTION_IDS) {
var type = InventoryComponent.getComponentTypeById(sectionId);
if (type == null) {
continue;
}
InventoryComponent component = holder.getComponent(type);
captureSection(slots, sectionId, component == null ? null : component.getInventory());
}
return slots;
}
private static void captureSection(
@Nonnull List<InventorySlotRecord> slots,
int sectionId,
@Nullable ItemContainer container) {
if (container == null) {
return;
}
for (short slot = 0; slot < container.getCapacity(); slot++) {
ItemStack stack = container.getItemStack(slot);
if (ItemStack.isEmpty(stack)) {
continue;
}
String itemId = stack.getItemId();
if (itemId == null || itemId.isBlank()) {
continue;
}
Double durability = stack.getMaxDurability() > 0 ? stack.getDurability() : null;
Double maxDurability = stack.getMaxDurability() > 0 ? stack.getMaxDurability() : null;
slots.add(new InventorySlotRecord(
sectionId,
slot,
itemId,
stack.getQuantity(),
durability,
maxDurability,
metadataJson(stack)));
}
}
public void applyToPlayer(
@Nonnull List<InventorySlotRecord> slots,
@Nonnull Store<EntityStore> store,
@@ -103,7 +133,10 @@ public final class CharacterInventoryService {
@Nonnull
private static ItemStack buildStack(@Nonnull InventorySlotRecord slot) {
ItemStack stack = new ItemStack(slot.itemId(), slot.quantity());
BsonDocument metadata = parseMetadata(slot.metadataJson());
ItemStack stack = metadata == null
? new ItemStack(slot.itemId(), slot.quantity())
: new ItemStack(slot.itemId(), slot.quantity(), metadata);
if (slot.maxDurability() != null && slot.maxDurability() > 0) {
stack = stack.withMaxDurability(slot.maxDurability());
if (slot.durability() != null) {
@@ -113,16 +146,28 @@ public final class CharacterInventoryService {
return stack;
}
@Nullable
private static String metadataJson(@Nonnull ItemStack stack) {
BsonDocument metadata = stack.getMetadata();
if (metadata == null || metadata.isEmpty()) {
return null;
}
return metadata.toJson();
}
@Nullable
private static BsonDocument parseMetadata(@Nullable String metadataJson) {
if (metadataJson == null || metadataJson.isBlank()) {
return null;
}
return BsonDocument.parse(metadataJson);
}
@Nullable
private static ItemContainer sectionContainer(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
int sectionId) {
var type = InventoryComponent.getComponentTypeById(sectionId);
if (type == null) {
return null;
}
InventoryComponent component = store.getComponent(ref, type);
return component == null ? null : component.getInventory();
return InventoryUtils.getSectionById(ref, sectionId, store);
}
}
@@ -1,20 +1,24 @@
package com.disklexar.mmorpg.player;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.combat.MmorpgScheduler;
import com.disklexar.mmorpg.core.config.MmorpgConfig;
import com.disklexar.mmorpg.persistence.repository.CharacterRepository;
import com.disklexar.mmorpg.persistence.repository.PlayerAccountRepository;
import com.disklexar.mmorpg.player.model.PlayerAccount;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
import com.disklexar.mmorpg.rpg.clazz.ClassWeaponService;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.disklexar.mmorpg.rpg.race.RaceCatalog;
import com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import com.disklexar.mmorpg.ui.AbilityBarService;
import com.disklexar.mmorpg.ui.CharacterSelectPage;
import com.hypixel.hytale.component.Holder;
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.protocol.packets.interface_.Page;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
import com.hypixel.hytale.server.core.universe.PlayerRef;
@@ -63,7 +67,7 @@ public final class CharacterService {
@Nonnull
public List<PlayerProfile> listCharacters(@Nonnull UUID accountUuid) throws SQLException {
return characters.findByAccountUuid(accountUuid);
return characters.findAliveByAccountUuid(accountUuid);
}
@Nonnull
@@ -71,7 +75,7 @@ public final class CharacterService {
@Nonnull UUID accountUuid,
@Nonnull String query) throws SQLException {
String trimmed = query.trim();
for (PlayerProfile profile : characters.findByAccountUuid(accountUuid)) {
for (PlayerProfile profile : characters.findAliveByAccountUuid(accountUuid)) {
if (profile.getCharacterId().toString().equalsIgnoreCase(trimmed)
|| profile.getDisplayName().equalsIgnoreCase(trimmed)) {
return Optional.of(profile);
@@ -174,6 +178,65 @@ public final class CharacterService {
return CharacterDeletionResult.success(found.get().getDisplayName());
}
/**
* Resolves character selection on join without Custom UI (asset pack not required).
*/
public void handleJoinSelection(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UUID accountUuid) {
try {
List<PlayerProfile> characters = listCharacters(accountUuid);
if (characters.isEmpty()) {
CharacterCreationResult created = createDefaultCharacter(
accountUuid, playerRef.getUsername());
if (created.ok() && created.profile() != null) {
activateCharacter(
playerRef, ref, store, accountUuid, created.profile().getCharacterId());
} else {
String error = created.errorMessage() != null
? created.errorMessage()
: "Création impossible.";
playerRef.sendMessage(Message.raw(error));
playerRef.sendMessage(Message.raw(
"Créez un personnage : /mmorpg character creer [nom]"));
}
return;
}
if (characters.size() == 1) {
activateCharacter(
playerRef, ref, store, accountUuid, characters.get(0).getCharacterId());
return;
}
PlayerSessionService.AccountSession session = sessions.getAccountSession(accountUuid);
UUID activeId = session == null ? null : session.account().getActiveCharacterId();
if (activeId != null) {
for (PlayerProfile profile : characters) {
if (profile.getCharacterId().equals(activeId)) {
activateCharacter(playerRef, ref, store, accountUuid, activeId);
return;
}
}
}
sessions.beginCharacterSelection(accountUuid);
showCharacterSelectorUi(playerRef, ref, store, accountUuid, null);
} catch (SQLException e) {
logger.at(Level.WARNING).log(
"Failed join character selection for %s: %s",
playerRef.getUsername(),
e.getMessage());
playerRef.sendMessage(Message.raw(
"Erreur lors du chargement de vos personnages."));
playerRef.sendMessage(Message.raw(
"Utilisez /mmorpg character liste puis /mmorpg character choisir <nom>"));
}
}
public void openCharacterSelector(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@@ -190,15 +253,49 @@ public final class CharacterService {
bootstrap.getRegistry().require(AbilityBarService.class).dismiss(accountUuid);
}
showCharacterSelectorUi(playerRef, ref, store, accountUuid, null);
}
/**
* Prompts character selection via chat (after respawn or voluntary switch).
*/
public void showCharacterSelectorUi(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UUID accountUuid,
@Nullable String deathCharacterName) {
InventoryUtils.clear(ref, store);
if (deathCharacterName != null) {
playerRef.sendMessage(Message.raw(
"Votre personnage « " + deathCharacterName + " » est mort définitivement."));
playerRef.sendMessage(Message.raw("Choisissez un autre personnage ou créez-en un nouveau."));
}
Player player = store.getComponent(ref, Player.getComponentType());
if (player != null) {
player.getPageManager().openCustomPage(ref, store, new com.disklexar.mmorpg.ui.CharacterSelectPage(
playerRef,
accountUuid,
this,
config.getMaxCharactersPerAccount()));
try {
player.getPageManager().openCustomPage(ref, store, new CharacterSelectPage(
playerRef,
accountUuid,
this,
config.getMaxCharactersPerAccount()));
return;
} catch (Throwable t) {
logger.at(Level.WARNING).log(
"Character selector UI failed for %s: %s",
playerRef.getUsername(),
t.getMessage());
}
}
try {
sendCharacterSelectionPrompt(playerRef, listCharacters(accountUuid));
} catch (SQLException e) {
logger.at(Level.WARNING).log(
"Failed to list characters for %s: %s", playerRef.getUsername(), e.getMessage());
playerRef.sendMessage(Message.raw("Erreur lors du chargement de vos personnages."));
}
}
@@ -212,6 +309,9 @@ public final class CharacterService {
if (found.isEmpty() || !found.get().getAccountUuid().equals(accountUuid)) {
throw new SQLException("Character not found for account: " + characterId);
}
if (found.get().isDead()) {
throw new SQLException("Character is dead: " + characterId);
}
PlayerSessionService.AccountSession session = sessions.requireSession(accountUuid);
if (session.activeCharacter() != null
@@ -227,6 +327,13 @@ public final class CharacterService {
InventoryUtils.clear(ref, store);
inventory.loadToPlayer(characterId, store, ref);
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
if (playerClass != null && ClassCatalog.ARCHER.id().equals(playerClass.id())) {
if (ClassWeaponService.ensureArcherArrowsIfMissing(store, ref)) {
inventory.saveFromPlayer(characterId, store, ref);
}
}
account.setActiveCharacterId(characterId);
account.touch();
accounts.setActiveCharacter(accountUuid, characterId);
@@ -234,26 +341,159 @@ public final class CharacterService {
sessions.activateCharacter(accountUuid, profile);
completeLogin(playerRef, ref, store, profile);
var bootstrap = plugin.getBootstrap();
if (bootstrap != null) {
CharacterStatService characterStats = bootstrap.getRegistry().require(CharacterStatService.class);
characterStats.applyToEntity(store, ref, profile);
}
}
public void persistActiveCharacter(
@Nonnull PlayerSessionService.AccountSession session,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
persistActiveCharacterInternal(session, characterId ->
inventory.saveFromPlayer(characterId, store, ref));
}
public void persistActiveCharacter(
@Nonnull PlayerSessionService.AccountSession session,
@Nonnull PlayerRef playerRef) {
Ref<EntityStore> ref = playerRef.getReference();
if (ref != null && ref.isValid()) {
persistActiveCharacter(session, ref.getStore(), ref);
return;
}
Holder<EntityStore> holder = playerRef.getHolder();
if (holder != null) {
persistActiveCharacterInternal(session, characterId ->
inventory.saveFromHolder(characterId, holder));
}
}
/**
* Saves every connected player's inventory to SQLite (called periodically while online).
*/
public void persistAllOnlineInventories(@Nonnull OnlinePlayerRegistry onlinePlayers) {
for (OnlinePlayer online : onlinePlayers.all()) {
PlayerSessionService.AccountSession session =
sessions.getAccountSession(online.playerRef().getUuid());
if (session == null || session.activeCharacter() == null) {
continue;
}
Ref<EntityStore> ref = online.entityRef();
if (ref == null || !ref.isValid()) {
continue;
}
online.world().execute(() -> persistActiveCharacter(
session, online.world().getEntityStore().getStore(), ref));
}
}
private void persistActiveCharacterInternal(
@Nonnull PlayerSessionService.AccountSession session,
@Nonnull InventorySaveAction saveAction) {
PlayerProfile active = session.activeCharacter();
if (active == null) {
return;
}
try {
sessions.flushCharacterPlaytime(session.account().getUuid());
active.touch();
characters.save(active);
inventory.saveFromPlayer(active.getCharacterId(), store, ref);
saveAction.run(active.getCharacterId());
} catch (SQLException e) {
logger.at(Level.WARNING).log(
"Failed to persist character %s: %s", active.getCharacterId(), e.getMessage());
}
}
@FunctionalInterface
private interface InventorySaveAction {
void run(@Nonnull UUID characterId) throws SQLException;
}
/**
* Persists death data immediately; the selector opens after respawn.
*/
public void retireActiveCharacterOnDeath(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UUID accountUuid) {
PlayerSessionService.AccountSession session = sessions.getAccountSession(accountUuid);
if (session == null || session.activeCharacter() == null) {
return;
}
PlayerProfile active = session.activeCharacter();
try {
sessions.flushCharacterPlaytime(accountUuid);
active.touch();
active.markDead(System.currentTimeMillis());
inventory.saveFromPlayer(active.getCharacterId(), store, ref);
characters.save(active);
PlayerAccount account = session.account();
account.setActiveCharacterId(null);
account.touch();
accounts.setActiveCharacter(accountUuid, null);
accounts.save(account);
logger.at(Level.INFO).log(
"Character %s (%s) died permanently for account %s",
active.getDisplayName(),
active.getCharacterId(),
accountUuid);
var bootstrap = plugin.getBootstrap();
if (bootstrap != null) {
bootstrap.getRegistry().require(OnlinePlayerRegistry.class).remove(accountUuid);
bootstrap.getRegistry().require(AbilityBarService.class).dismiss(accountUuid);
}
sessions.beginCharacterSelection(accountUuid);
sessions.markSelectorAfterRespawn(accountUuid, active.getDisplayName());
} catch (SQLException e) {
logger.at(Level.WARNING).log(
"Failed to process character death for %s: %s", active.getCharacterId(), e.getMessage());
}
}
/**
* Opens the character selector once the player has respawned.
*/
public void openCharacterSelectorAfterRespawn(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UUID accountUuid) {
String characterName = sessions.consumeSelectorAfterRespawn(accountUuid);
if (characterName == null) {
return;
}
showCharacterSelectorUi(playerRef, ref, store, accountUuid, characterName);
}
public void openClassSelector(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull PlayerProfile profile,
boolean mandatory,
boolean welcomeAfterPick) {
PlayerProfile active = sessions.getSession(playerRef.getUuid());
sendClassSelectionPrompt(playerRef, active != null ? active : profile);
}
public void welcomeAfterFirstClassChoice(
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile) {
sendWelcomeMessages(playerRef, profile);
}
private void completeLogin(
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> ref,
@@ -269,7 +509,71 @@ public final class CharacterService {
if (player != null) {
onlinePlayers.add(new OnlinePlayer(
profile.getUuid(), playerRef, ref, player.getWorld()));
player.getPageManager().setPage(ref, store, Page.None);
}
ClassService classService = bootstrap.getRegistry().require(ClassService.class);
OnlinePlayer online = onlinePlayers.get(profile.getUuid());
if (online != null) {
bootstrap.getRegistry().require(AbilityBarService.class).sync(online, profile);
}
if (!classService.hasClass(profile)) {
sendClassSelectionPrompt(playerRef, profile);
return;
}
sendWelcomeMessages(playerRef, profile);
}
private void sendCharacterSelectionPrompt(
@Nonnull PlayerRef playerRef,
@Nonnull List<PlayerProfile> profiles) {
playerRef.sendMessage(Message.raw("=== Sélection du personnage ==="));
if (profiles.isEmpty()) {
playerRef.sendMessage(Message.raw("Aucun personnage vivant."));
playerRef.sendMessage(Message.raw("Créez-en un : /mmorpg character creer [nom]"));
return;
}
for (int i = 0; i < profiles.size(); i++) {
PlayerProfile profile = profiles.get(i);
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
String className = playerClass == null ? "Sans classe" : playerClass.displayName();
playerRef.sendMessage(Message.raw(String.format(
Locale.FRENCH,
"%d. %s — Nv.%d — %s",
i + 1,
profile.getDisplayName(),
profile.getLevel(),
className)));
}
playerRef.sendMessage(Message.raw("Choisissez : /mmorpg character choisir <nom>"));
playerRef.sendMessage(Message.raw("Nouveau : /mmorpg character creer [nom]"));
}
private void sendClassSelectionPrompt(
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile) {
playerRef.sendMessage(Message.raw("=== Choisissez votre classe ==="));
playerRef.sendMessage(Message.raw("Personnage : " + profile.getDisplayName()));
for (PlayerClass playerClass : ClassCatalog.all()) {
playerRef.sendMessage(Message.raw(String.format(
Locale.FRENCH,
"• %s (%s) — %s",
playerClass.displayName(),
playerClass.id(),
playerClass.description())));
}
playerRef.sendMessage(Message.raw("Commande : /mmorpg class choisir <classe>"));
playerRef.sendMessage(Message.raw("Ex. : /mmorpg class choisir knight"));
}
private void sendWelcomeMessages(
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile) {
var bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return;
}
playerRef.sendMessage(Message.raw(String.format(
@@ -278,13 +582,12 @@ public final class CharacterService {
profile.getLevel(),
RaceCatalog.byId(profile.getRaceId()).displayName())));
playerRef.sendMessage(Message.raw(
"Commandes : /mmorpg menu | /mmorpg character | /mmorpg class info"));
"Commandes : /mmorpg menu | /mmorpg character | /mmorpg class selecteur"));
ClassService classService = bootstrap.getRegistry().require(ClassService.class);
AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class);
MmorpgScheduler scheduler = bootstrap.getRegistry().require(MmorpgScheduler.class);
if (classService.hasClass(profile)) {
scheduler.runLater(() -> abilityBar.sync(profile.getUuid(), profile), 5_000L);
playerRef.sendMessage(Message.raw(
"Capacités : touches Use ability 1/2/3 ou /mmorpg ability <1|2|3>"));
}
}
@@ -8,6 +8,8 @@ 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 com.disklexar.mmorpg.rpg.stats.CharacterStat;
import com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import javax.annotation.Nonnull;
import java.time.Duration;
@@ -42,6 +44,10 @@ public final class PlayerInfoView {
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("Points de stats", String.valueOf(profile.getStatPoints())));
for (CharacterStat stat : CharacterStat.values()) {
entries.add(new Entry(stat.displayName(), String.valueOf(profile.getStat(stat))));
}
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"));
@@ -14,6 +14,7 @@ import javax.annotation.Nullable;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
@@ -46,6 +47,9 @@ public final class PlayerSessionService implements Service {
private final HytaleLogger logger;
private final Map<UUID, AccountSession> sessions = new ConcurrentHashMap<>();
private final Map<UUID, Long> connectedAt = new ConcurrentHashMap<>();
private final Map<UUID, Long> characterActiveSince = new ConcurrentHashMap<>();
private final Set<UUID> pendingSelectorAfterRespawn = ConcurrentHashMap.newKeySet();
private final Map<UUID, String> pendingDeathCharacterNames = new ConcurrentHashMap<>();
public PlayerSessionService(
@Nonnull MmorpgPlugin plugin,
@@ -82,6 +86,9 @@ public final class PlayerSessionService implements Service {
}
sessions.clear();
connectedAt.clear();
characterActiveSince.clear();
pendingSelectorAfterRespawn.clear();
pendingDeathCharacterNames.clear();
logger.at(Level.INFO).log("Player session service stopped");
}
@@ -121,12 +128,14 @@ public final class PlayerSessionService implements Service {
profile.applyAccountSession(current.account());
AccountSession updated = new AccountSession(current.account(), profile, false);
sessions.put(accountUuid, updated);
characterActiveSince.put(accountUuid, System.currentTimeMillis());
}
/**
* Puts the account back into character-selection mode (active character cleared from session).
*/
public void beginCharacterSelection(@Nonnull UUID accountUuid) {
flushCharacterPlaytime(accountUuid);
AccountSession current = sessions.get(accountUuid);
if (current == null) {
return;
@@ -177,6 +186,19 @@ public final class PlayerSessionService implements Service {
}
}
public void markSelectorAfterRespawn(@Nonnull UUID accountUuid, @Nonnull String characterName) {
pendingSelectorAfterRespawn.add(accountUuid);
pendingDeathCharacterNames.put(accountUuid, characterName);
}
@Nullable
public String consumeSelectorAfterRespawn(@Nonnull UUID accountUuid) {
if (!pendingSelectorAfterRespawn.remove(accountUuid)) {
return null;
}
return pendingDeathCharacterNames.remove(accountUuid);
}
public void saveActiveCharacter(@Nonnull UUID accountUuid) {
AccountSession session = sessions.get(accountUuid);
if (session == null || session.activeCharacter() == null) {
@@ -185,16 +207,26 @@ public final class PlayerSessionService implements Service {
saveCharacterQuietly(session.activeCharacter());
}
private void accumulatePlaytime(@Nonnull UUID accountUuid) {
Long start = connectedAt.remove(accountUuid);
/**
* Flushes elapsed play time for the active character slice (not account total).
*/
public void flushCharacterPlaytime(@Nonnull UUID accountUuid) {
Long characterStart = characterActiveSince.remove(accountUuid);
AccountSession session = sessions.get(accountUuid);
if (start == null || session == null) {
if (characterStart == null || session == null || session.activeCharacter() == null) {
return;
}
session.account().addTimePlay(System.currentTimeMillis() - start);
if (session.activeCharacter() != null) {
session.activeCharacter().applyAccountSession(session.account());
session.activeCharacter().addTimePlay(System.currentTimeMillis() - characterStart);
}
private void accumulatePlaytime(@Nonnull UUID accountUuid) {
flushCharacterPlaytime(accountUuid);
Long accountStart = connectedAt.remove(accountUuid);
AccountSession session = sessions.get(accountUuid);
if (accountStart == null || session == null) {
return;
}
session.account().addTimePlay(System.currentTimeMillis() - accountStart);
}
private void saveAccountQuietly(@Nonnull PlayerAccount account) {
@@ -1,5 +1,7 @@
package com.disklexar.mmorpg.player.model;
import com.disklexar.mmorpg.rpg.stats.CharacterStat;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.LinkedHashSet;
@@ -31,6 +33,15 @@ public final class PlayerProfile {
private long money;
private String raceId = "human";
@Nullable
private Long deathTime;
private int statPoints;
private int statForce;
private int statVitality;
private int statDexterity;
private int statSpeed;
private final long dateCreation;
private long updatedAt;
@@ -169,6 +180,56 @@ public final class PlayerProfile {
this.raceId = raceId;
}
public boolean isDead() {
return deathTime != null;
}
@Nullable
public Long getDeathTime() {
return deathTime;
}
public void setDeathTime(@Nullable Long deathTime) {
this.deathTime = deathTime;
}
public void markDead(long when) {
this.deathTime = when;
}
public int getStatPoints() {
return statPoints;
}
public void setStatPoints(int statPoints) {
this.statPoints = Math.max(0, statPoints);
}
public void addStatPoints(int amount) {
if (amount > 0) {
this.statPoints += amount;
}
}
public int getStat(@Nonnull CharacterStat stat) {
return switch (stat) {
case FORCE -> statForce;
case VITALITY -> statVitality;
case DEXTERITY -> statDexterity;
case SPEED -> statSpeed;
};
}
public void setStat(@Nonnull CharacterStat stat, int value) {
int clamped = Math.max(0, value);
switch (stat) {
case FORCE -> statForce = clamped;
case VITALITY -> statVitality = clamped;
case DEXTERITY -> statDexterity = clamped;
case SPEED -> statSpeed = clamped;
}
}
public long getDateCreation() {
return dateCreation;
}
@@ -184,7 +245,6 @@ public final class PlayerProfile {
/** Copies account-level session fields onto this character view. */
public void applyAccountSession(@Nonnull PlayerAccount account) {
setConnected(account.isConnected());
setTotalTimePlay(account.getTotalTimePlay());
setLastDateConnected(account.getLastDateConnected());
}
@@ -3,6 +3,7 @@ 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 com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import javax.annotation.Nonnull;
@@ -43,11 +44,16 @@ public final class ProgressionService {
}
private void applyLevelUps(@Nonnull PlayerProfile profile) {
int previousLevel = profile.getLevel();
long needed = xpForLevel(profile.getLevel());
while (profile.getExperience() >= needed) {
profile.setExperience(profile.getExperience() - needed);
profile.setLevel(profile.getLevel() + 1);
needed = xpForLevel(profile.getLevel());
}
int levelsGained = profile.getLevel() - previousLevel;
if (levelsGained > 0) {
profile.addStatPoints(levelsGained * CharacterStatService.POINTS_PER_LEVEL);
}
}
}
@@ -55,7 +55,7 @@ public final class ClassCatalog {
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),
"Quadruple la cadence de tir de l'arc (4 tirs/s) 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",
@@ -88,6 +88,14 @@ public final class ClassCatalog {
"Pendant 10s, chaque coup applique un poison (dégâts magiques/seconde pendant 5s, cumulable).",
13_000L)));
private static final Map<String, String> WEAPON_ITEM_IDS = Map.of(
WEAPON_LONGSWORD, "Weapon_Longsword_Mithril",
WEAPON_BATTLEAXE, "Weapon_Battleaxe_Mithril",
WEAPON_BOW, "Weapon_Shortbow_Cobalt",
WEAPON_CROSSBOW, "Weapon_Crossbow_Ancient_Steel",
WEAPON_STAFF, "Weapon_Staff_Mithril",
WEAPON_DAGGERS, "Weapon_Daggers_Mithril");
private static final Map<String, PlayerClass> BY_ID = new LinkedHashMap<>();
static {
@@ -116,4 +124,9 @@ public final class ClassCatalog {
public static List<PlayerClass> all() {
return List.copyOf(BY_ID.values());
}
@Nullable
public static String weaponItemId(@Nonnull String weaponKey) {
return WEAPON_ITEM_IDS.get(weaponKey);
}
}
@@ -0,0 +1,73 @@
package com.disklexar.mmorpg.rpg.clazz;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.bootstrap.Bootstrap;
import com.disklexar.mmorpg.player.CharacterService;
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.ui.AbilityBarService;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Shared logic for applying a class choice (command or UI).
*/
public final class ClassSelectionSupport {
private ClassSelectionSupport() {
}
/**
* Assigns the class, equips the starter weapon, syncs the ability bar and persists data.
*
* @return the chosen class, or {@code null} on failure.
*/
@Nullable
public static PlayerClass apply(
@Nonnull MmorpgPlugin plugin,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile,
@Nonnull String classId) {
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return null;
}
ClassService classService = bootstrap.getRegistry().require(ClassService.class);
if (!classService.choose(profile, classId)) {
return null;
}
PlayerClass chosen = classService.getClass(profile);
if (chosen != null) {
ClassWeaponService.giveStarterWeapon(store, ref, chosen);
}
AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class);
OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class);
OnlinePlayer online = onlinePlayers.get(playerRef.getUuid());
if (online != null) {
abilityBar.sync(online, profile);
}
CharacterService characters = bootstrap.getRegistry().require(CharacterService.class);
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
PlayerSessionService.AccountSession session = sessions.getAccountSession(playerRef.getUuid());
if (session != null) {
characters.persistActiveCharacter(session, store, ref);
} else {
sessions.saveActiveCharacter(playerRef.getUuid());
}
return chosen;
}
}
@@ -0,0 +1,110 @@
package com.disklexar.mmorpg.rpg.clazz;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
import com.hypixel.hytale.server.core.inventory.ItemStack;
import com.hypixel.hytale.server.core.inventory.container.ItemContainer;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Locale;
/**
* Equips the starter weapon for a chosen class.
*/
public final class ClassWeaponService {
private static final byte HOTBAR_WEAPON_SLOT = 0;
private static final byte HOTBAR_AMMO_SLOT = 1;
private static final String ARCHER_ARROW_ITEM = "Weapon_Arrow_Crude";
private static final int ARCHER_ARROW_QUANTITY = 64;
private ClassWeaponService() {
}
/**
* Places the class starter weapon in the hotbar and selects it.
* Archers also receive a stack of arrows in the adjacent hotbar slot.
*
* @return the item id given, or {@code null} when no weapon could be equipped.
*/
@Nullable
public static String giveStarterWeapon(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull PlayerClass playerClass) {
String itemId = playerClass.starterWeaponItemId();
if (itemId == null || itemId.isBlank()) {
return null;
}
ItemContainer hotbar = InventoryUtils.getSectionById(
ref, InventoryComponent.HOTBAR_SECTION_ID, store);
if (hotbar == null) {
return null;
}
hotbar.setItemStackForSlot(HOTBAR_WEAPON_SLOT, new ItemStack(itemId, 1));
if (ClassCatalog.ARCHER.id().equals(playerClass.id())) {
ensureArcherArrowsIfMissing(store, ref);
}
InventoryUtils.setActiveSlot(
ref, InventoryComponent.HOTBAR_SECTION_ID, HOTBAR_WEAPON_SLOT, store);
return itemId;
}
/**
* Gives archer starter arrows when none are present (hotbar, storage or utility).
*
* @return {@code true} when a stack was added.
*/
public static boolean ensureArcherArrowsIfMissing(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
if (countArrows(store, ref) > 0) {
return false;
}
ItemContainer hotbar = InventoryUtils.getSectionById(
ref, InventoryComponent.HOTBAR_SECTION_ID, store);
if (hotbar == null) {
return false;
}
hotbar.setItemStackForSlot(HOTBAR_AMMO_SLOT, new ItemStack(ARCHER_ARROW_ITEM, ARCHER_ARROW_QUANTITY));
return true;
}
public static int countArrows(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
int total = 0;
total += countArrowsInSection(store, ref, InventoryComponent.HOTBAR_SECTION_ID);
total += countArrowsInSection(store, ref, InventoryComponent.STORAGE_SECTION_ID);
total += countArrowsInSection(store, ref, InventoryComponent.UTILITY_SECTION_ID);
return total;
}
private static int countArrowsInSection(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
int sectionId) {
ItemContainer container = InventoryUtils.getSectionById(ref, sectionId, store);
if (container == null) {
return 0;
}
int count = 0;
for (short slot = 0; slot < container.getCapacity(); slot++) {
ItemStack stack = container.getItemStack(slot);
if (ItemStack.isEmpty(stack)) {
continue;
}
String itemId = stack.getItemId();
if (itemId != null && itemId.toLowerCase(Locale.ROOT).contains("arrow")) {
count += stack.getQuantity();
}
}
return count;
}
}
@@ -1,6 +1,7 @@
package com.disklexar.mmorpg.rpg.clazz;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.List;
/**
@@ -21,4 +22,13 @@ public record PlayerClass(
}
return false;
}
/** Starter weapon item id given when the player picks this class. */
@Nullable
public String starterWeaponItemId() {
if (weapons.isEmpty()) {
return null;
}
return ClassCatalog.weaponItemId(weapons.get(0));
}
}
@@ -0,0 +1,43 @@
package com.disklexar.mmorpg.rpg.stats;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Allocatable character attributes. Each point grants a fixed combat bonus (see {@link CharacterStatService}).
*/
public enum CharacterStat {
FORCE("force", "Force"),
VITALITY("vitality", "Vitalité"),
DEXTERITY("dexterity", "Dextérité"),
SPEED("speed", "Vitesse");
private final String id;
private final String displayName;
CharacterStat(@Nonnull String id, @Nonnull String displayName) {
this.id = id;
this.displayName = displayName;
}
@Nonnull
public String id() {
return id;
}
@Nonnull
public String displayName() {
return displayName;
}
@Nullable
public static CharacterStat byId(@Nonnull String raw) {
String normalized = raw.trim().toLowerCase();
for (CharacterStat stat : values()) {
if (stat.id.equals(normalized)) {
return stat;
}
}
return null;
}
}
@@ -0,0 +1,161 @@
package com.disklexar.mmorpg.rpg.stats;
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.protocol.MovementSettings;
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap;
import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes;
import com.hypixel.hytale.server.core.modules.entitystats.modifier.Modifier;
import com.hypixel.hytale.server.core.modules.entitystats.modifier.StaticModifier;
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;
/**
* Applies and persists character stat bonuses:
* <ul>
* <li>Force — +2 flat physical damage per point</li>
* <li>Vitalité — +10 max health per point</li>
* <li>Dextérité — +1% attack speed per point (weapon swing rate + shorter ability cooldowns)</li>
* <li>Vitesse — +2 movement speed per point</li>
* </ul>
* Players receive {@link #POINTS_PER_LEVEL} unspent points each time they level up.
*/
public final class CharacterStatService {
public static final int POINTS_PER_LEVEL = 10;
private static final float FORCE_ATTACK_PER_POINT = 2f;
private static final float VITALITY_HEALTH_PER_POINT = 10f;
private static final double DEXTERITY_ATTACK_SPEED_PER_POINT = 0.01;
private static final float SPEED_MOVEMENT_PER_POINT = 2f;
private static final String HEALTH_MODIFIER_KEY = "mmorpg_vitality";
private final PlayerSessionService sessions;
public CharacterStatService(@Nonnull PlayerSessionService sessions) {
this.sessions = sessions;
}
public boolean allocatePoint(
@Nonnull UUID accountUuid,
@Nonnull PlayerProfile profile,
@Nonnull CharacterStat stat,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
if (profile.getStatPoints() <= 0) {
return false;
}
profile.setStatPoints(profile.getStatPoints() - 1);
profile.setStat(stat, profile.getStat(stat) + 1);
profile.touch();
sessions.saveActiveCharacter(accountUuid);
applyToEntity(store, ref, profile);
return true;
}
public void applyToEntity(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull PlayerProfile profile) {
applyHealthBonus(store, ref, profile);
applyMovementSpeed(store, ref, profile);
}
public float attackBonus(@Nonnull PlayerProfile profile) {
return profile.getStat(CharacterStat.FORCE) * FORCE_ATTACK_PER_POINT;
}
public double attackSpeedFactor(@Nonnull PlayerProfile profile) {
return 1.0 + profile.getStat(CharacterStat.DEXTERITY) * DEXTERITY_ATTACK_SPEED_PER_POINT;
}
/**
* Extra interaction time shift applied each tick before the vanilla interaction manager runs.
* {@code 1} dexterity point = {@value #DEXTERITY_ATTACK_SPEED_PER_POINT} (1%) faster weapon swings.
*/
public float attackSpeedTimeShift(@Nonnull PlayerProfile profile) {
return (float) (profile.getStat(CharacterStat.DEXTERITY) * DEXTERITY_ATTACK_SPEED_PER_POINT);
}
public long cooldownMillis(@Nonnull PlayerProfile profile, long baseCooldownMillis) {
if (baseCooldownMillis <= 0L) {
return baseCooldownMillis;
}
double factor = attackSpeedFactor(profile);
return Math.max(1L, Math.round(baseCooldownMillis / factor));
}
public float movementSpeedBonus(@Nonnull PlayerProfile profile) {
return profile.getStat(CharacterStat.SPEED) * SPEED_MOVEMENT_PER_POINT;
}
@Nonnull
public static String formatStatLine(@Nonnull PlayerProfile profile, @Nonnull CharacterStat stat) {
int value = profile.getStat(stat);
return switch (stat) {
case FORCE -> stat.displayName() + " " + value + " (+" + Math.round(value * FORCE_ATTACK_PER_POINT) + " attaque)";
case VITALITY -> stat.displayName() + " " + value + " (+" + Math.round(value * VITALITY_HEALTH_PER_POINT) + " vie)";
case DEXTERITY -> stat.displayName() + " " + value + " (+" + value + "% vitesse d'attaque)";
case SPEED -> stat.displayName() + " " + value + " (+" + Math.round(value * SPEED_MOVEMENT_PER_POINT) + " déplacement)";
};
}
@Nonnull
public static String formatBonusLine(@Nonnull CharacterStat stat) {
return switch (stat) {
case FORCE -> "+" + (int) FORCE_ATTACK_PER_POINT + " attaque par point";
case VITALITY -> "+" + (int) VITALITY_HEALTH_PER_POINT + " vie par point";
case DEXTERITY -> "+1% vitesse d'attaque par point";
case SPEED -> "+" + (int) SPEED_MOVEMENT_PER_POINT + " déplacement par point";
};
}
private void applyHealthBonus(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull PlayerProfile profile) {
EntityStatMap statMap = store.getComponent(ref, EntityStatMap.getComponentType());
if (statMap == null) {
return;
}
float bonus = profile.getStat(CharacterStat.VITALITY) * VITALITY_HEALTH_PER_POINT;
statMap.putModifier(
DefaultEntityStatTypes.getHealth(),
HEALTH_MODIFIER_KEY,
new StaticModifier(
Modifier.ModifierTarget.MAX,
StaticModifier.CalculationType.ADDITIVE,
bonus));
statMap.update();
}
private void applyMovementSpeed(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull PlayerProfile profile) {
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 target = defaults.baseSpeed + movementSpeedBonus(profile);
if (Math.abs(current.baseSpeed - target) > 0.0001f) {
current.baseSpeed = target;
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef != null) {
movement.update(playerRef.getPacketHandler());
}
}
}
}
@@ -32,6 +32,8 @@ import java.util.logging.Level;
*/
public final class AbilityBarService implements Service {
private static final boolean CUSTOM_HUD_ENABLED = true;
private final HytaleLogger logger;
private final ClassService classService;
private final AbilityService abilityService;
@@ -62,7 +64,9 @@ public final class AbilityBarService implements Service {
@Override
public void start() {
refreshTask = scheduler.runRepeating(this::refreshAll, 5_000L, 1_000L);
if (CUSTOM_HUD_ENABLED) {
refreshTask = scheduler.runRepeating(this::refreshAll, 5_000L, 1_000L);
}
}
@Override
@@ -75,6 +79,9 @@ public final class AbilityBarService implements Service {
}
public void sync(@Nonnull UUID playerUuid, @Nonnull PlayerProfile profile) {
if (!CUSTOM_HUD_ENABLED) {
return;
}
OnlinePlayer online = onlinePlayers.get(playerUuid);
if (online == null) {
return;
@@ -83,6 +90,9 @@ public final class AbilityBarService implements Service {
}
public void sync(@Nonnull OnlinePlayer online, @Nonnull PlayerProfile profile) {
if (!CUSTOM_HUD_ENABLED) {
return;
}
PlayerClass playerClass = classService.getClass(profile);
if (playerClass == null) {
dismiss(online.uuid());
@@ -93,6 +103,10 @@ public final class AbilityBarService implements Service {
}
public void dismiss(@Nonnull UUID playerUuid) {
if (!CUSTOM_HUD_ENABLED) {
activeHuds.remove(playerUuid);
return;
}
OnlinePlayer online = onlinePlayers.get(playerUuid);
if (online == null) {
activeHuds.remove(playerUuid);
@@ -102,6 +116,10 @@ public final class AbilityBarService implements Service {
}
public void dismiss(@Nonnull OnlinePlayer online) {
if (!CUSTOM_HUD_ENABLED) {
activeHuds.remove(online.uuid());
return;
}
AbilityBarHud hud = activeHuds.remove(online.uuid());
if (hud == null) {
return;
@@ -12,6 +12,8 @@ import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime;
import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType;
import com.hypixel.hytale.protocol.packets.interface_.Page;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.entity.entities.player.pages.InteractiveCustomUIPage;
import com.hypixel.hytale.server.core.ui.builder.EventData;
import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
@@ -29,6 +31,7 @@ import java.util.logging.Logger;
/**
* Mandatory character selector shown on join before gameplay begins.
* Uses the plugin asset pack document {@code Pages/MmorpgCharacterSelect.ui}.
*/
public final class CharacterSelectPage extends InteractiveCustomUIPage<CharacterSelectPage.UiEvent> {
@@ -142,6 +145,10 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
@Nonnull UUID characterId) {
try {
characters.activateCharacter(playerRef, ref, store, accountUuid, characterId);
Player player = store.getComponent(ref, Player.getComponentType());
if (player != null) {
player.getPageManager().setPage(ref, store, Page.None);
}
} catch (SQLException e) {
LOGGER.log(Level.WARNING, "Character selection failed", e);
setStatus(ref, store, "Impossible de charger ce personnage.");
@@ -0,0 +1,183 @@
package com.disklexar.mmorpg.ui;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.ClassSelectionSupport;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
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.packets.interface_.CustomPageLifetime;
import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType;
import com.hypixel.hytale.protocol.packets.interface_.Page;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.entity.entities.player.pages.InteractiveCustomUIPage;
import com.hypixel.hytale.server.core.ui.builder.EventData;
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 javax.annotation.Nullable;
import java.util.List;
import java.util.function.Consumer;
/**
* Class picker — mandatory on first character play, optional via command.
*/
public final class ClassSelectPage extends InteractiveCustomUIPage<ClassSelectPage.UiEvent> {
private static final String DOCUMENT = "Pages/MmorpgClassSelect.ui";
private static final int CARD_COUNT = 4;
private final MmorpgPlugin plugin;
private final PlayerRef playerRef;
private final PlayerProfile profile;
private final boolean welcomeAfterPick;
@Nullable
private final Consumer<PlayerProfile> onChosen;
public ClassSelectPage(
@Nonnull MmorpgPlugin plugin,
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile,
boolean mandatory,
boolean welcomeAfterPick,
@Nullable Consumer<PlayerProfile> onChosen) {
super(playerRef, mandatory ? CustomPageLifetime.CantClose : CustomPageLifetime.CanDismiss, UiEvent.CODEC);
this.plugin = plugin;
this.playerRef = playerRef;
this.profile = profile;
this.welcomeAfterPick = welcomeAfterPick;
this.onChosen = onChosen;
}
@Override
public void build(
@Nonnull Ref<EntityStore> ref,
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull Store<EntityStore> store) {
ui.append(DOCUMENT);
ui.set("#Title.Text", escape("Choisissez votre classe"));
ui.set("#Subtitle.Text", escape("Personnage : " + profile.getDisplayName()));
renderClassCards(ui, events, footerHint());
}
@Override
public void handleDataEvent(
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UiEvent data) {
super.handleDataEvent(ref, store, data);
if (data.action == null || !"select".equalsIgnoreCase(data.action) || data.classId == null) {
return;
}
Player player = store.getComponent(ref, Player.getComponentType());
if (player == null) {
return;
}
player.getWorld().execute(() -> applyClassChoice(ref, store, data.classId));
}
private void applyClassChoice(
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull String classId) {
PlayerClass chosen = ClassSelectionSupport.apply(
plugin, store, ref, playerRef, profile, classId);
if (chosen == null) {
setStatus("Classe inconnue.");
return;
}
Player player = store.getComponent(ref, Player.getComponentType());
if (player != null) {
player.getPageManager().setPage(ref, store, Page.None);
}
playerRef.sendMessage(Message.raw("Classe choisie : " + chosen.displayName()));
if (onChosen != null) {
onChosen.accept(profile);
} else if (welcomeAfterPick) {
playerRef.sendMessage(Message.raw(
"Votre arme de classe a été équipée. Bon jeu !"));
}
}
private void setStatus(@Nonnull String message) {
UICommandBuilder ui = new UICommandBuilder();
UIEventBuilder events = new UIEventBuilder();
renderClassCards(ui, events, message);
sendUpdate(ui, events, false);
}
@Nonnull
private String footerHint() {
return profile.getClassId() == null
? "Cliquez sur Choisir pour valider une classe"
: "Cliquez sur Choisir pour changer de classe";
}
private void renderClassCards(
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull String footer) {
for (int index = 0; index < CARD_COUNT; index++) {
ui.set("#ClassCard" + index + ".Visible", false);
}
List<PlayerClass> classes = ClassCatalog.all();
for (int index = 0; index < Math.min(classes.size(), CARD_COUNT); index++) {
PlayerClass playerClass = classes.get(index);
ui.set("#ClassCard" + index + ".Visible", true);
ui.set("#ClassName" + index + ".Text", escape(playerClass.displayName()));
ui.set(
"#ClassDesc" + index + ".Text",
escape(playerClass.description() + " — Armes : " + String.join(", ", playerClass.weapons())));
ui.set("#ClassPick" + index + ".Text", escape("Choisir " + playerClass.displayName()));
EventData selectEvent = EventData.of("Action", "select")
.append("ClassId", playerClass.id());
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#ClassPick" + index,
selectEvent,
false);
}
ui.set("#FooterHint.Text", escape(footer));
}
@Nonnull
private static String escape(@Nonnull String text) {
return text.replace("\\", "\\\\").replace("\"", "\\\"");
}
public static final class UiEvent {
public static final BuilderCodec<UiEvent> CODEC = BuilderCodec.builder(UiEvent.class, UiEvent::new)
.append(new KeyedCodec<>("Action", Codec.STRING),
(UiEvent event, String value) -> event.action = value,
event -> event.action)
.add()
.append(new KeyedCodec<>("ClassId", Codec.STRING),
(UiEvent event, String value) -> event.classId = value,
event -> event.classId)
.add()
.build();
private String action;
private String classId;
public UiEvent() {
}
}
}
@@ -3,6 +3,7 @@ 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.stats.CharacterStatService;
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.google.gson.JsonElement;
@@ -36,6 +37,8 @@ public final class PlayerInventoryPage extends InteractiveCustomUIPage<PlayerInv
private final PlayerProfile profile;
private final ProgressionService progression;
private final AbilityService abilities;
@Nullable
private final CharacterStatService characterStats;
@Nullable
private PlayerInventorySlotView.SlotRef pendingPick;
@@ -45,11 +48,21 @@ public final class PlayerInventoryPage extends InteractiveCustomUIPage<PlayerInv
@Nonnull PlayerProfile profile,
@Nullable ProgressionService progression,
@Nullable AbilityService abilities) {
this(playerRef, profile, progression, abilities, null);
}
public PlayerInventoryPage(
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile,
@Nullable ProgressionService progression,
@Nullable AbilityService abilities,
@Nullable CharacterStatService characterStats) {
super(playerRef, CustomPageLifetime.CanDismiss, UiEvent.CODEC);
this.playerRef = playerRef;
this.profile = profile;
this.progression = progression;
this.abilities = abilities;
this.characterStats = characterStats;
}
@Override
@@ -83,6 +96,7 @@ public final class PlayerInventoryPage extends InteractiveCustomUIPage<PlayerInv
profile,
progression,
abilities,
characterStats,
tab));
}
return;
@@ -166,9 +180,11 @@ public final class PlayerInventoryPage extends InteractiveCustomUIPage<PlayerInv
private void applyTabHighlight(@Nonnull UICommandBuilder ui) {
ui.set("#TabCharacterIndicator.Visible", false);
ui.set("#TabInventoryIndicator.Visible", true);
ui.set("#TabStatsIndicator.Visible", false);
ui.set("#TabSkillsIndicator.Visible", false);
ui.set("#TabCharacterWrap.Background", "#00000000");
ui.set("#TabInventoryWrap.Background", "#3a3f4a");
ui.set("#TabStatsWrap.Background", "#00000000");
ui.set("#TabSkillsWrap.Background", "#00000000");
}
@@ -228,6 +244,11 @@ public final class PlayerInventoryPage extends InteractiveCustomUIPage<PlayerInv
"#TabInventory",
EventData.of("Tab", "inventory"),
false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#TabStats",
EventData.of("Tab", "stats"),
false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#TabSkills",
@@ -252,6 +273,7 @@ public final class PlayerInventoryPage extends InteractiveCustomUIPage<PlayerInv
return switch (value) {
case "character", "personnage" -> PlayerMenuPage.Tab.CHARACTER;
case "inventory", "inventaire" -> PlayerMenuPage.Tab.INVENTORY;
case "stats", "statistiques", "statistics" -> PlayerMenuPage.Tab.STATS;
case "skills", "competences", "compétences" -> PlayerMenuPage.Tab.SKILLS;
default -> null;
};
@@ -11,6 +11,8 @@ 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 com.disklexar.mmorpg.rpg.stats.CharacterStat;
import com.disklexar.mmorpg.rpg.stats.CharacterStatService;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.hypixel.hytale.codec.builder.BuilderCodec;
@@ -36,15 +38,16 @@ import java.util.StringJoiner;
import java.util.UUID;
/**
* MMORPG player menu with tabs: Personnage, Inventaire, Compétences.
* MMORPG player menu with tabs: Personnage, Inventaire, Statistiques, Compétences.
* <p>
* Inventaire uses {@code MmorpgInventoryPage.ui} as the root document with {@code ItemSlot} icons.
* Personnage / Compétences use {@code MmorpgPlayerShell.ui}.
* Personnage / Statistiques / Compétences use {@code MmorpgPlayerShell.ui}.
*/
public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage.UiEvent> {
private static final String SHELL = "Pages/MmorpgPlayerShell.ui";
private static final String CHARACTER_TAB = "Pages/MmorpgCharacterTab.ui";
private static final String STATS_TAB = "Pages/MmorpgStatsTab.ui";
private static final String SKILLS_TAB = "Pages/MmorpgSkillsTab.ui";
private static final DateTimeFormatter DATE_FORMAT =
@@ -53,6 +56,7 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
public enum Tab {
CHARACTER,
INVENTORY,
STATS,
SKILLS
}
@@ -60,6 +64,8 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
private final PlayerProfile profile;
private final ProgressionService progression;
private final AbilityService abilities;
@Nullable
private final CharacterStatService characterStats;
private Tab activeTab;
public PlayerMenuPage(
@@ -68,11 +74,22 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
@Nullable ProgressionService progression,
@Nullable AbilityService abilities,
@Nonnull Tab initialTab) {
this(playerRef, profile, progression, abilities, null, initialTab);
}
public PlayerMenuPage(
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile,
@Nullable ProgressionService progression,
@Nullable AbilityService abilities,
@Nullable CharacterStatService characterStats,
@Nonnull Tab initialTab) {
super(playerRef, CustomPageLifetime.CanDismiss, UiEvent.CODEC);
this.playerRef = playerRef;
this.profile = profile;
this.progression = progression;
this.abilities = abilities;
this.characterStats = characterStats;
this.activeTab = initialTab;
}
@@ -90,6 +107,18 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull String raw) {
CharacterStat stat = parseStat(raw);
if (stat != null && characterStats != null) {
if (characterStats.allocatePoint(profile.getUuid(), profile, stat, store, ref)) {
UICommandBuilder ui = new UICommandBuilder();
UIEventBuilder events = new UIEventBuilder();
bindTabButtons(events);
loadShellTab(ui, events, store, ref, activeTab);
sendUpdate(ui, events, false);
}
return;
}
Tab tab = parseTab(raw);
if (tab != null && tab != activeTab) {
if (tab == Tab.INVENTORY) {
@@ -138,9 +167,14 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
switch (tab) {
case CHARACTER -> {
ui.append("#DynamicTabHost", CHARACTER_TAB);
applyCharacterTab(ui, store, ref);
applyCharacterTab(ui, events, store, ref);
ui.set("#FooterHint.Text", escape("Échap pour fermer — onglet Personnage"));
}
case STATS -> {
ui.append("#DynamicTabHost", STATS_TAB);
applyStatsTab(ui, events);
ui.set("#FooterHint.Text", escape("Échap pour fermer — onglet Statistiques"));
}
case SKILLS -> {
ui.append("#DynamicTabHost", SKILLS_TAB);
applySkillsTab(ui);
@@ -155,9 +189,11 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
private void applyTabHighlight(@Nonnull UICommandBuilder ui, @Nonnull Tab tab) {
ui.set("#TabCharacterIndicator.Visible", tab == Tab.CHARACTER);
ui.set("#TabInventoryIndicator.Visible", tab == Tab.INVENTORY);
ui.set("#TabStatsIndicator.Visible", tab == Tab.STATS);
ui.set("#TabSkillsIndicator.Visible", tab == Tab.SKILLS);
ui.set("#TabCharacterWrap.Background", tab == Tab.CHARACTER ? "#3a3f4a" : "#00000000");
ui.set("#TabInventoryWrap.Background", tab == Tab.INVENTORY ? "#3a3f4a" : "#00000000");
ui.set("#TabStatsWrap.Background", tab == Tab.STATS ? "#3a3f4a" : "#00000000");
ui.set("#TabSkillsWrap.Background", tab == Tab.SKILLS ? "#3a3f4a" : "#00000000");
}
@@ -169,6 +205,7 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
private void applyCharacterTab(
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
@@ -194,11 +231,6 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
ui.set("#StaminaValue.Text", escape("Endurance : —"));
}
int level = profile.getLevel();
ui.set("#StatStr.Text", escape("FOR " + (10 + level)));
ui.set("#StatAgi.Text", escape("AGI " + (8 + level)));
ui.set("#StatInt.Text", escape("INT " + (6 + level)));
ui.set("#PowersList.Text", escape(formatPowers()));
ui.set("#JobsList.Text", escape(formatJobs()));
ui.set("#GroupLabel.Text", escape(
@@ -211,6 +243,62 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
ui.set("#CreatedLabel.Text", escape("Création : " + formatDate(profile.getDateCreation())));
}
private void applyStatsTab(@Nonnull UICommandBuilder ui, @Nonnull UIEventBuilder events) {
ui.set("#StatPointsLabel.Text", escape(
"Points à attribuer : " + profile.getStatPoints() + " (+"
+ CharacterStatService.POINTS_PER_LEVEL + " par niveau)"));
for (CharacterStat stat : CharacterStat.values()) {
ui.set(statBonusSelector(stat) + ".Text", escape(
stat.displayName() + " : " + CharacterStatService.formatBonusLine(stat)));
}
applyStatAllocation(ui, events);
}
private void applyStatAllocation(@Nonnull UICommandBuilder ui, @Nonnull UIEventBuilder events) {
boolean canAllocate = profile.getStatPoints() > 0 && characterStats != null;
for (CharacterStat stat : CharacterStat.values()) {
ui.set(statLabelSelector(stat) + ".Text", escape(CharacterStatService.formatStatLine(profile, stat)));
ui.set(statAddSelector(stat) + ".Visible", canAllocate);
if (canAllocate) {
events.addEventBinding(
CustomUIEventBindingType.Activating,
statAddSelector(stat),
EventData.of("Stat", stat.id()),
false);
}
}
}
@Nonnull
private static String statBonusSelector(@Nonnull CharacterStat stat) {
return switch (stat) {
case FORCE -> "#StatForceBonus";
case VITALITY -> "#StatVitalityBonus";
case DEXTERITY -> "#StatDexterityBonus";
case SPEED -> "#StatSpeedBonus";
};
}
@Nonnull
private static String statLabelSelector(@Nonnull CharacterStat stat) {
return switch (stat) {
case FORCE -> "#StatForceLabel";
case VITALITY -> "#StatVitalityLabel";
case DEXTERITY -> "#StatDexterityLabel";
case SPEED -> "#StatSpeedLabel";
};
}
@Nonnull
private static String statAddSelector(@Nonnull CharacterStat stat) {
return switch (stat) {
case FORCE -> "#StatForceAdd";
case VITALITY -> "#StatVitalityAdd";
case DEXTERITY -> "#StatDexterityAdd";
case SPEED -> "#StatSpeedAdd";
};
}
private void applySkillsTab(@Nonnull UICommandBuilder ui) {
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
if (playerClass == null) {
@@ -253,6 +341,11 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
"#TabInventory",
EventData.of("Tab", "inventory"),
false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#TabStats",
EventData.of("Tab", "stats"),
false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#TabSkills",
@@ -315,6 +408,7 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
return switch (tab) {
case CHARACTER -> "Personnage";
case INVENTORY -> "Inventaire";
case STATS -> "Statistiques";
case SKILLS -> "Compétences";
};
}
@@ -323,6 +417,9 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
private static Tab parseTab(@Nonnull String raw) {
try {
JsonObject json = JsonParser.parseString(raw).getAsJsonObject();
if (json.has("Stat")) {
return null;
}
if (!json.has("Tab")) {
return null;
}
@@ -330,6 +427,7 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
return switch (value) {
case "character", "personnage" -> Tab.CHARACTER;
case "inventory", "inventaire" -> Tab.INVENTORY;
case "stats", "statistiques", "statistics" -> Tab.STATS;
case "skills", "competences", "compétences" -> Tab.SKILLS;
default -> null;
};
@@ -338,6 +436,19 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage<PlayerMenuPage
}
}
@Nullable
private static CharacterStat parseStat(@Nonnull String raw) {
try {
JsonObject json = JsonParser.parseString(raw).getAsJsonObject();
if (!json.has("Stat")) {
return null;
}
return CharacterStat.byId(json.get("Stat").getAsString());
} catch (RuntimeException ignored) {
return null;
}
}
@Nonnull
private static String formatDuration(long millis) {
if (millis <= 0) {
@@ -3,7 +3,6 @@
@MmorpgAccent = #f5c842;
@MmorpgText = #e8e8ec;
@MmorpgMuted = #878e9c;
@MmorpgDanger = #c9302c;
Group {
LayoutMode: Center;
@@ -38,119 +37,30 @@ Group {
Visible: false;
}
Group #CharRow0 {
LayoutMode: Left;
Padding: (Vertical: 6);
Anchor: (Bottom: 4);
Visible: false;
Label #CharName0 {
Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true);
Text: "Perso 1";
FlexWeight: 1;
}
TextButton #CharDelete0 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect0 {
Text: "Jouer";
Anchor: (Width: 88);
}
Group #CharRow0 { LayoutMode: Left; Padding: (Vertical: 6); Anchor: (Bottom: 4); Visible: false;
Label #CharName0 { Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); Text: "Perso 1"; FlexWeight: 1; }
TextButton #CharDelete0 { Text: "Suppr."; Anchor: (Width: 72, Right: 6); }
TextButton #CharSelect0 { Text: "Jouer"; Anchor: (Width: 88); }
}
Group #CharRow1 {
LayoutMode: Left;
Padding: (Vertical: 6);
Anchor: (Bottom: 4);
Visible: false;
Label #CharName1 {
Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true);
Text: "Perso 2";
FlexWeight: 1;
}
TextButton #CharDelete1 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect1 {
Text: "Jouer";
Anchor: (Width: 88);
}
Group #CharRow1 { LayoutMode: Left; Padding: (Vertical: 6); Anchor: (Bottom: 4); Visible: false;
Label #CharName1 { Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); Text: "Perso 2"; FlexWeight: 1; }
TextButton #CharDelete1 { Text: "Suppr."; Anchor: (Width: 72, Right: 6); }
TextButton #CharSelect1 { Text: "Jouer"; Anchor: (Width: 88); }
}
Group #CharRow2 {
LayoutMode: Left;
Padding: (Vertical: 6);
Anchor: (Bottom: 4);
Visible: false;
Label #CharName2 {
Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true);
Text: "Perso 3";
FlexWeight: 1;
}
TextButton #CharDelete2 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect2 {
Text: "Jouer";
Anchor: (Width: 88);
}
Group #CharRow2 { LayoutMode: Left; Padding: (Vertical: 6); Anchor: (Bottom: 4); Visible: false;
Label #CharName2 { Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); Text: "Perso 3"; FlexWeight: 1; }
TextButton #CharDelete2 { Text: "Suppr."; Anchor: (Width: 72, Right: 6); }
TextButton #CharSelect2 { Text: "Jouer"; Anchor: (Width: 88); }
}
Group #CharRow3 {
LayoutMode: Left;
Padding: (Vertical: 6);
Anchor: (Bottom: 4);
Visible: false;
Label #CharName3 {
Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true);
Text: "Perso 4";
FlexWeight: 1;
}
TextButton #CharDelete3 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect3 {
Text: "Jouer";
Anchor: (Width: 88);
}
Group #CharRow3 { LayoutMode: Left; Padding: (Vertical: 6); Anchor: (Bottom: 4); Visible: false;
Label #CharName3 { Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); Text: "Perso 4"; FlexWeight: 1; }
TextButton #CharDelete3 { Text: "Suppr."; Anchor: (Width: 72, Right: 6); }
TextButton #CharSelect3 { Text: "Jouer"; Anchor: (Width: 88); }
}
Group #CharRow4 {
LayoutMode: Left;
Padding: (Vertical: 6);
Anchor: (Bottom: 4);
Visible: false;
Label #CharName4 {
Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true);
Text: "Perso 5";
FlexWeight: 1;
}
TextButton #CharDelete4 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect4 {
Text: "Jouer";
Anchor: (Width: 88);
}
Group #CharRow4 { LayoutMode: Left; Padding: (Vertical: 6); Anchor: (Bottom: 4); Visible: false;
Label #CharName4 { Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); Text: "Perso 5"; FlexWeight: 1; }
TextButton #CharDelete4 { Text: "Suppr."; Anchor: (Width: 72, Right: 6); }
TextButton #CharSelect4 { Text: "Jouer"; Anchor: (Width: 88); }
}
}
@@ -169,34 +79,16 @@ Group {
Group #ConfirmActions {
LayoutMode: Left;
TextButton #ConfirmDeleteButton {
Text: "Confirmer la suppression";
FlexWeight: 1;
Anchor: (Right: 8);
}
TextButton #CancelDeleteButton {
Text: "Annuler";
FlexWeight: 1;
}
TextButton #ConfirmDeleteButton { Text: "Confirmer la suppression"; FlexWeight: 1; Anchor: (Right: 8); }
TextButton #CancelDeleteButton { Text: "Annuler"; FlexWeight: 1; }
}
}
Group #Actions {
LayoutMode: Left;
Anchor: (Top: 12);
TextButton #CreateCharacterButton {
Text: "Nouveau personnage";
FlexWeight: 1;
Anchor: (Right: 8);
}
TextButton #RefreshButton {
Text: "Actualiser";
FlexWeight: 1;
}
TextButton #CreateCharacterButton { Text: "Nouveau personnage"; FlexWeight: 1; Anchor: (Right: 8); }
TextButton #RefreshButton { Text: "Actualiser"; FlexWeight: 1; }
}
Label #FooterHint {
@@ -10,197 +10,162 @@
@XpBg = PatchStyle(Color: #152030);
Group {
LayoutMode: Left;
LayoutMode: Top;
Group #LeftColumn {
LayoutMode: Top;
Anchor: (Width: 400, Right: 16);
Group {
LayoutMode: Left;
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "IDENTITÉ";
Anchor: (Bottom: 6);
}
Group #LeftColumn {
LayoutMode: Top;
Anchor: (Width: 400, Right: 16);
Label #IdentityName {
Style: (FontSize: 18, RenderBold: true, TextColor: @MmorpgAccent);
Text: "Joueur";
Anchor: (Bottom: 4);
}
Label #RaceLabel {
Style: (FontSize: 13, TextColor: @MmorpgText);
Text: "Race : Humain";
Anchor: (Bottom: 2);
}
Label #ClassLabel {
Style: (FontSize: 13, TextColor: @MmorpgText);
Text: "Classe : —";
Anchor: (Bottom: 10);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "PROGRESSION";
Anchor: (Bottom: 6);
}
Label #XpValue {
Style: (FontSize: 11, TextColor: @MmorpgText);
Text: "0 / 100 XP";
Anchor: (Bottom: 4);
}
ProgressBar #XpBar {
Anchor: (Width: 360, Height: 14, Bottom: 10);
Bar: @XpFill;
Background: @XpBg;
Value: 0.0;
Direction: End;
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "VITALITÉ";
Anchor: (Bottom: 6);
}
Label #HealthValue {
Style: (FontSize: 10, TextColor: @MmorpgText);
Text: "Vie : —";
Anchor: (Bottom: 2);
}
ProgressBar #HealthBar {
Anchor: (Width: 360, Height: 12, Bottom: 6);
Bar: @HealthFill;
Background: @HealthBg;
Value: 1.0;
Direction: End;
}
Label #StaminaValue {
Style: (FontSize: 10, TextColor: @MmorpgText);
Text: "Endurance : —";
Anchor: (Bottom: 2);
}
ProgressBar #StaminaBar {
Anchor: (Width: 360, Height: 12, Bottom: 10);
Bar: @StaminaFill;
Background: @StaminaBg;
Value: 1.0;
Direction: End;
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "ATTRIBUTS";
Anchor: (Bottom: 6);
}
Group {
LayoutMode: Left;
Anchor: (Bottom: 4);
Label #StatStr {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "FOR 10";
Padding: (Horizontal: 10, Vertical: 4);
Background: #2a3140;
Anchor: (Right: 6);
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "IDENTITÉ";
Anchor: (Bottom: 6);
}
Label #StatAgi {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "AGI 10";
Padding: (Horizontal: 10, Vertical: 4);
Background: #2a3140;
Anchor: (Right: 6);
Label #IdentityName {
Style: (FontSize: 18, RenderBold: true, TextColor: @MmorpgAccent);
Text: "Joueur";
Anchor: (Bottom: 4);
}
Label #StatInt {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "INT 10";
Padding: (Horizontal: 10, Vertical: 4);
Background: #2a3140;
Label #RaceLabel {
Style: (FontSize: 13, TextColor: @MmorpgText);
Text: "Race : Humain";
Anchor: (Bottom: 2);
}
Label #ClassLabel {
Style: (FontSize: 13, TextColor: @MmorpgText);
Text: "Classe : —";
Anchor: (Bottom: 10);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "PROGRESSION";
Anchor: (Bottom: 6);
}
Label #XpValue {
Style: (FontSize: 11, TextColor: @MmorpgText);
Text: "0 / 100 XP";
Anchor: (Bottom: 4);
}
ProgressBar #XpBar {
Anchor: (Width: 360, Height: 14, Bottom: 10);
Bar: @XpFill;
Background: @XpBg;
Value: 0.0;
Direction: End;
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "VITALITÉ";
Anchor: (Bottom: 6);
}
Label #HealthValue {
Style: (FontSize: 10, TextColor: @MmorpgText);
Text: "Vie : —";
Anchor: (Bottom: 2);
}
ProgressBar #HealthBar {
Anchor: (Width: 360, Height: 12, Bottom: 6);
Bar: @HealthFill;
Background: @HealthBg;
Value: 1.0;
Direction: End;
}
Label #StaminaValue {
Style: (FontSize: 10, TextColor: @MmorpgText);
Text: "Endurance : —";
Anchor: (Bottom: 2);
}
ProgressBar #StaminaBar {
Anchor: (Width: 360, Height: 12, Bottom: 10);
Bar: @StaminaFill;
Background: @StaminaBg;
Value: 1.0;
Direction: End;
}
}
Label #StatsHint {
Style: (FontSize: 9, TextColor: @MmorpgMuted);
Text: "Attributs dérivés du niveau (système M1 à venir)";
}
}
Group #RightColumn {
LayoutMode: Top;
FlexWeight: 1;
Group #RightColumn {
LayoutMode: Top;
FlexWeight: 1;
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "POUVOIRS PASSIFS";
Anchor: (Bottom: 4);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "POUVOIRS PASSIFS";
Anchor: (Bottom: 4);
}
Label #PowersList {
Style: (FontSize: 12, TextColor: @MmorpgText, Wrap: true);
Text: "Aucun";
Anchor: (Bottom: 10);
}
Label #PowersList {
Style: (FontSize: 12, TextColor: @MmorpgText, Wrap: true);
Text: "Aucun";
Anchor: (Bottom: 10);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "MÉTIERS";
Anchor: (Bottom: 4);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "MÉTIERS";
Anchor: (Bottom: 4);
}
Label #JobsList {
Style: (FontSize: 12, TextColor: @MmorpgText, Wrap: true);
Text: "Aucun";
Anchor: (Bottom: 10);
}
Label #JobsList {
Style: (FontSize: 12, TextColor: @MmorpgText, Wrap: true);
Text: "Aucun";
Anchor: (Bottom: 10);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "SOCIAL";
Anchor: (Bottom: 4);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "SOCIAL";
Anchor: (Bottom: 4);
}
Label #GroupLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Groupe : Aucun";
Anchor: (Bottom: 2);
}
Label #GroupLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Groupe : Aucun";
Anchor: (Bottom: 2);
}
Label #GuildLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Guilde : Aucune";
Anchor: (Bottom: 10);
}
Label #GuildLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Guilde : Aucune";
Anchor: (Bottom: 10);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "ACTIVITÉ";
Anchor: (Bottom: 4);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "STATISTIQUES";
Anchor: (Bottom: 4);
}
Label #PlayTimeLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Temps de jeu : 0 min";
Anchor: (Bottom: 2);
}
Label #PlayTimeLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Temps de jeu : 0 min";
Anchor: (Bottom: 2);
}
Label #LastLoginLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Dernière connexion : —";
Anchor: (Bottom: 2);
}
Label #LastLoginLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Dernière connexion : —";
Anchor: (Bottom: 2);
}
Label #CreatedLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Création : —";
Label #CreatedLabel {
Style: (FontSize: 12, TextColor: @MmorpgText);
Text: "Création : —";
}
}
}
}
@@ -0,0 +1,95 @@
@MmorpgBg = #0d0f14(0.98);
@MmorpgPanel = #1a1c20;
@MmorpgSlot = #2a3140;
@MmorpgAccent = #f5c842;
@MmorpgText = #e8e8ec;
@MmorpgMuted = #878e9c;
Group {
LayoutMode: Center;
Group #ClassSelectRoot {
LayoutMode: Top;
Anchor: (Width: 720, Height: 520);
Background: @MmorpgBg;
Padding: (Full: 20);
Label #Title {
Style: (FontSize: 22, RenderBold: true, TextColor: @MmorpgAccent, HorizontalAlignment: Center);
Text: "Choisissez votre classe";
Anchor: (Bottom: 6);
}
Label #Subtitle {
Style: (FontSize: 14, TextColor: @MmorpgText, HorizontalAlignment: Center);
Text: "Cliquez sur Choisir pour valider";
Anchor: (Bottom: 16);
}
Group #ClassGrid {
LayoutMode: Top;
FlexWeight: 1;
Background: @MmorpgPanel;
Padding: (Full: 14);
Group #ClassRow0 {
LayoutMode: Left;
Anchor: (Bottom: 12);
Group #ClassCard0 {
LayoutMode: Top;
Padding: (Full: 12);
Background: @MmorpgSlot;
Anchor: (Width: 320, Right: 12);
Visible: false;
Label #ClassName0 { Style: (FontSize: 16, RenderBold: true, TextColor: @MmorpgAccent, HorizontalAlignment: Center); Text: "Classe"; Anchor: (Bottom: 4); }
Label #ClassDesc0 { Style: (FontSize: 12, TextColor: @MmorpgMuted, Wrap: true, HorizontalAlignment: Center); Text: "Description"; Anchor: (Bottom: 8); }
TextButton #ClassPick0 { Text: "Choisir"; }
}
Group #ClassCard1 {
LayoutMode: Top;
Padding: (Full: 12);
Background: @MmorpgSlot;
Anchor: (Width: 320);
Visible: false;
Label #ClassName1 { Style: (FontSize: 16, RenderBold: true, TextColor: @MmorpgAccent, HorizontalAlignment: Center); Text: "Classe"; Anchor: (Bottom: 4); }
Label #ClassDesc1 { Style: (FontSize: 12, TextColor: @MmorpgMuted, Wrap: true, HorizontalAlignment: Center); Text: "Description"; Anchor: (Bottom: 8); }
TextButton #ClassPick1 { Text: "Choisir"; }
}
}
Group #ClassRow1 {
LayoutMode: Left;
Group #ClassCard2 {
LayoutMode: Top;
Padding: (Full: 12);
Background: @MmorpgSlot;
Anchor: (Width: 320, Right: 12);
Visible: false;
Label #ClassName2 { Style: (FontSize: 16, RenderBold: true, TextColor: @MmorpgAccent, HorizontalAlignment: Center); Text: "Classe"; Anchor: (Bottom: 4); }
Label #ClassDesc2 { Style: (FontSize: 12, TextColor: @MmorpgMuted, Wrap: true, HorizontalAlignment: Center); Text: "Description"; Anchor: (Bottom: 8); }
TextButton #ClassPick2 { Text: "Choisir"; }
}
Group #ClassCard3 {
LayoutMode: Top;
Padding: (Full: 12);
Background: @MmorpgSlot;
Anchor: (Width: 320);
Visible: false;
Label #ClassName3 { Style: (FontSize: 16, RenderBold: true, TextColor: @MmorpgAccent, HorizontalAlignment: Center); Text: "Classe"; Anchor: (Bottom: 4); }
Label #ClassDesc3 { Style: (FontSize: 12, TextColor: @MmorpgMuted, Wrap: true, HorizontalAlignment: Center); Text: "Description"; Anchor: (Bottom: 8); }
TextButton #ClassPick3 { Text: "Choisir"; }
}
}
}
Label #FooterHint {
Style: (FontSize: 11, TextColor: @MmorpgMuted, HorizontalAlignment: Center);
Text: "Vous devez choisir une classe pour jouer";
Anchor: (Top: 12);
}
}
}
@@ -102,6 +102,23 @@ Group {
}
}
Group #TabStatsWrap {
LayoutMode: Top;
Padding: (Horizontal: 12, Vertical: 4);
Anchor: (Right: 6);
Background: PatchStyle(Color: #00000000);
TextButton #TabStats {
Text: "Statistiques";
}
Group #TabStatsIndicator {
Anchor: (Height: 2, Top: 4);
Background: PatchStyle(Color: @MmorpgAccent);
Visible: false;
}
}
Group #TabSkillsWrap {
LayoutMode: Top;
Padding: (Horizontal: 12, Vertical: 4);
@@ -92,6 +92,23 @@ Group {
}
}
Group #TabStatsWrap {
LayoutMode: Top;
Padding: (Horizontal: 12, Vertical: 4);
Anchor: (Right: 6);
Background: PatchStyle(Color: #00000000);
TextButton #TabStats {
Text: "Statistiques";
}
Group #TabStatsIndicator {
Anchor: (Height: 2, Top: 4);
Background: PatchStyle(Color: @MmorpgAccent);
Visible: false;
}
}
Group #TabSkillsWrap {
LayoutMode: Top;
Padding: (Horizontal: 12, Vertical: 4);
@@ -0,0 +1,65 @@
@MmorpgAccent = #f5c842;
@MmorpgText = #e8e8ec;
@MmorpgMuted = #878e9c;
Group {
LayoutMode: Top;
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "ATTRIBUTS";
Anchor: (Bottom: 4);
}
Label #StatPointsLabel {
Style: (FontSize: 14, RenderBold: true, TextColor: @MmorpgAccent);
Text: "Points à attribuer : 0";
Anchor: (Bottom: 12);
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "RÉPARTITION";
Anchor: (Bottom: 8);
}
Group {
LayoutMode: Top;
Background: #141820;
Padding: (Full: 10);
Anchor: (Bottom: 12);
Group #StatForceRow { LayoutMode: Left; Anchor: (Bottom: 6);
Label #StatForceLabel { Style: (FontSize: 12, TextColor: @MmorpgText); Text: "Force 0"; FlexWeight: 1; }
TextButton #StatForceAdd { Text: "+"; Anchor: (Width: 32); Visible: false; }
}
Group #StatVitalityRow { LayoutMode: Left; Anchor: (Bottom: 6);
Label #StatVitalityLabel { Style: (FontSize: 12, TextColor: @MmorpgText); Text: "Vitalité 0"; FlexWeight: 1; }
TextButton #StatVitalityAdd { Text: "+"; Anchor: (Width: 32); Visible: false; }
}
Group #StatDexterityRow { LayoutMode: Left; Anchor: (Bottom: 6);
Label #StatDexterityLabel { Style: (FontSize: 12, TextColor: @MmorpgText); Text: "Dextérité 0"; FlexWeight: 1; }
TextButton #StatDexterityAdd { Text: "+"; Anchor: (Width: 32); Visible: false; }
}
Group #StatSpeedRow { LayoutMode: Left; Anchor: (Bottom: 2);
Label #StatSpeedLabel { Style: (FontSize: 12, TextColor: @MmorpgText); Text: "Vitesse 0"; FlexWeight: 1; }
TextButton #StatSpeedAdd { Text: "+"; Anchor: (Width: 32); Visible: false; }
}
}
Label {
Style: (FontSize: 11, RenderBold: true, TextColor: @MmorpgMuted);
Text: "EFFETS PAR POINT";
Anchor: (Bottom: 6);
}
Label #StatForceBonus { Style: (FontSize: 11, TextColor: @MmorpgText); Text: "Force : +2 attaque"; Anchor: (Bottom: 3); }
Label #StatVitalityBonus { Style: (FontSize: 11, TextColor: @MmorpgText); Text: "Vitalité : +10 vie"; Anchor: (Bottom: 3); }
Label #StatDexterityBonus { Style: (FontSize: 11, TextColor: @MmorpgText); Text: "Dextérité : +1% vitesse d'attaque"; Anchor: (Bottom: 3); }
Label #StatSpeedBonus { Style: (FontSize: 11, TextColor: @MmorpgText); Text: "Vitesse : +2 déplacement"; Anchor: (Bottom: 8); }
Label #StatsHint {
Style: (FontSize: 10, TextColor: @MmorpgMuted, Wrap: true);
Text: "Gagnez 10 points à chaque niveau. Cliquez + pour attribuer un point. Toutes les statistiques commencent à 0.";
}
}
@@ -0,0 +1,9 @@
{
"Type": "Projectile",
"RunTime": 0.2,
"Effects": {
"ItemAnimationId": "ShootCharged",
"ClearAnimationOnFinish": true
},
"Config": "Projectile_Config_Arrow_Shortbow_Strength_4"
}
@@ -0,0 +1,200 @@
{
"TranslationProperties": {
"Name": "server.items.Weapon_Arrow_Crude.name"
},
"Categories": [
"Items.Weapons"
],
"Quality": "Common",
"ItemLevel": 9,
"PlayerAnimationsId": "Dagger",
"Recipe": {
"TimeSeconds": 0,
"KnowledgeRequired": false,
"Input": [
{
"Quantity": 4,
"ItemId": "Ingredient_Stick"
},
{
"ResourceTypeId": "Rubble",
"Quantity": 1
}
],
"OutputQuantity": 4,
"BenchRequirement": [
{
"Type": "Crafting",
"Categories": [
"Workbench_Survival"
],
"Id": "Workbench"
},
{
"Type": "Crafting",
"Categories": [
"Weapon_Bow"
],
"Id": "Weapon_Bench"
}
]
},
"IconProperties": {
"Scale": 0.5,
"Rotation": [
45.0,
90.0,
0.0
],
"Translation": [
-25.0,
-25.0
]
},
"Model": "Items/Weapons/Arrow/Arrow.blockymodel",
"Texture": "Items/Weapons/Arrow/Crude_Texture.png",
"Icon": "Icons/ItemsGenerated/Weapon_Arrow_Crude.png",
"Interactions": {
"Primary": "Knife_Attack",
"Secondary": "Knife_Block"
},
"InteractionVars": {
"Knife_Block_Damage": "Knife_Block_Damage",
"Knife_Swing_Left_Effect": {
"Interactions": [
{
"Parent": "Knife_Swing_Left_Effect",
"Effects": {
"WorldSoundEventId": "SFX_Axe_Special_Swing"
}
}
]
},
"Knife_Swing_Left_Damage": {
"Interactions": [
{
"Parent": "Knife_Swing_Left_Damage",
"DamageCalculator": {
"BaseDamage": {
"Physical": 1
}
},
"DamageEffects": {
"Knockback": {
"Force": 0,
"VelocityType": "Add"
},
"WorldSoundEventId": "SFX_Axe_Special_Impact"
}
}
]
},
"Knife_Swing_Right_Effect": {
"Interactions": [
{
"Parent": "Knife_Swing_Right_Effect",
"Effects": {
"WorldSoundEventId": "SFX_Axe_Special_Swing"
}
}
]
},
"Knife_Swing_Right_Damage": {
"Interactions": [
{
"Parent": "Knife_Swing_Right_Damage",
"DamageCalculator": {
"BaseDamage": {
"Physical": 1
}
},
"DamageEffects": {
"Knockback": {
"Force": 0,
"VelocityType": "Add"
},
"WorldSoundEventId": "SFX_Axe_Special_Impact"
}
}
]
},
"Knife_Stab_Effect": {
"Interactions": [
{
"Parent": "Knife_Stab_Effect",
"Effects": {
"WorldSoundEventId": "SFX_Axe_Special_Swing"
}
}
]
},
"Knife_Stab_Damage": {
"Interactions": [
{
"Parent": "Knife_Stab_Damage",
"DamageCalculator": {
"BaseDamage": {
"Physical": 1
}
},
"DamageEffects": {
"Knockback": {
"Force": 0,
"VelocityType": "Add"
},
"WorldSoundEventId": "SFX_Axe_Special_Impact"
}
}
]
},
"Knife_Lunge_Effect": {
"Interactions": [
{
"Parent": "Knife_Lunge_Effect",
"Effects": {
"WorldSoundEventId": "SFX_Axe_Special_Swing"
}
}
]
},
"Knife_Lunge_Damage": {
"Interactions": [
{
"Parent": "Knife_Lunge_Damage",
"DamageCalculator": {
"BaseDamage": {
"Physical": 1
}
},
"DamageEffects": {
"Knockback": {
"Force": 0,
"VelocityType": "Add"
},
"WorldSoundEventId": "SFX_Axe_Special_Impact"
}
}
]
},
"Knife_Throw_Charged_Projectile": {
"Interactions": [
{
"Parent": "Knife_Throw_Charged_Projectile",
"ProjectileId": "Arrow_HalfCharge"
}
]
}
},
"MaxStack": 100,
"DroppedItemAnimation": "Items/Animations/Dropped/Dropped_Diagonal_Left.blockyanim",
"Tags": {
"Type": [
"Weapon"
],
"Family": [
"Arrow"
]
},
"Weapon": {},
"ItemSoundSetId": "ISS_Weapons_Arrows"
}
@@ -286,6 +286,14 @@
"MaxDurability": 150,
"DurabilityLossOnHit": 0.58,
"Interactions": {
"Primary": {
"RequireNewClick": true,
"Interactions": [
{
"Type": "mmorpg_archer_shoot"
}
]
},
"Ability1": {
"Interactions": [
{
@@ -0,0 +1,10 @@
{
"Interactions": [
"Mmorpg_Archer_Projectile"
],
"Tags": {
"Attack": [
"Ranged"
]
}
}
@@ -0,0 +1,6 @@
-- Per-character death timestamp and play time.
-- death_time: NULL while alive, epoch millis when permanently dead.
-- total_time_play: milliseconds spent on this character only.
ALTER TABLE characters ADD COLUMN death_time INTEGER;
ALTER TABLE characters ADD COLUMN total_time_play INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,7 @@
-- Allocatable character stats (Force, Vitalité, Dextérité, Vitesse).
ALTER TABLE characters ADD COLUMN stat_points INTEGER NOT NULL DEFAULT 0;
ALTER TABLE characters ADD COLUMN stat_force INTEGER NOT NULL DEFAULT 0;
ALTER TABLE characters ADD COLUMN stat_vitality INTEGER NOT NULL DEFAULT 0;
ALTER TABLE characters ADD COLUMN stat_dexterity INTEGER NOT NULL DEFAULT 0;
ALTER TABLE characters ADD COLUMN stat_speed INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1 @@
-- Four-stat model finalized (Force, Vitalité, Dextérité, Vitesse). No legacy columns to remove.
@@ -1,7 +1,7 @@
{
"Group": "com.disklexar",
"Name": "MMORPG",
"Version": "0.4.7",
"Version": "0.4.8",
"Description": "Serveur MMORPG Hytale — progression, persistance et systèmes sociaux",
"Authors": [
{
Binary file not shown.