diff --git a/Hytale/build.gradle.kts b/Hytale/build.gradle.kts index 54766e0..2085688 100644 --- a/Hytale/build.gradle.kts +++ b/Hytale/build.gradle.kts @@ -1,4 +1,5 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.api.file.DuplicatesStrategy plugins { id("com.gradleup.shadow") version "9.0.0-rc1" @@ -30,6 +31,7 @@ dependencies { tasks.shadowJar { archiveClassifier.set("") mergeServiceFiles() + duplicatesStrategy = DuplicatesStrategy.INCLUDE } tasks.jar { @@ -43,3 +45,43 @@ tasks.assemble { tasks.build { dependsOn(tasks.shadowJar) } + +tasks.register("cleanDevModUiAssets") { + delete("src/main/resources/Common") +} + +tasks.register("cleanDevModAssetPack") { + delete("devserver/mods/com.disklexar_MMORPG/Common") + delete("devserver/mods/com.disklexar_MMORPG/manifest.json") +} + +tasks.register("syncDevModAssetPack") { + dependsOn("cleanDevModAssetPack") + from("plugins/mmorpg/src/main/resources") { + include("Common/**") + include("manifest.json") + } + into("devserver/mods/com.disklexar_MMORPG") +} + +tasks.register("syncDevModAssets") { + dependsOn("generateManifest", "cleanDevModUiAssets", "syncDevModAssetPack") + from("plugins/mmorpg/src/main/resources") { + include("Server/**") + include("Common/**") + include("manifest.json") + } + into("src/main/resources") +} + +tasks.named("runServer") { + dependsOn("syncDevModAssets") +} + +tasks.named("setupServer") { + dependsOn("syncDevModAssets") +} + +tasks.named("processResources") { + finalizedBy("syncDevModAssets") +} diff --git a/Hytale/config/universe/worlds/default/spawn-provider.json b/Hytale/config/universe/worlds/default/spawn-provider.json new file mode 100644 index 0000000..4d331fe --- /dev/null +++ b/Hytale/config/universe/worlds/default/spawn-provider.json @@ -0,0 +1,13 @@ +{ + "SpawnProvider": { + "Type": "Global", + "SpawnPoint": { + "X": 195.0, + "Y": 98.0, + "Z": -44.0, + "Pitch": 0.0, + "Yaw": 0.0, + "Roll": 0.0 + } + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java index 8dd89d7..4cc12e5 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java @@ -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 -> { diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java index cc9eea4..810af1b 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java @@ -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); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java index f9948cf..4fa4fd4 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java @@ -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> 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 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 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 store, + @Nonnull Ref 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 target : TargetUtil.getAllEntitiesInSphere(point, hitRadius, store)) { - if (target.equals(casterRef)) { + for (Ref 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 store, + @Nonnull Ref 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 casterRef, @Nonnull Ref 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 store, + @Nonnull Ref casterRef, + @Nonnull Ref target, + float amount, + @Nonnull DamageCause cause) { + Damage damage = new Damage(new Damage.EntitySource(casterRef), cause, amount); DamageSystems.executeDamage(target, store, damage); } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/ArcherBowService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/ArcherBowService.java new file mode 100644 index 0000000..135da6f --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/ArcherBowService.java @@ -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 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 ref, + @Nonnull Store 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 store, @Nonnull Ref 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 store, @Nonnull Ref ref) { + return countArrows(store, ref) > 0; + } + + private static int countArrows(@Nonnull Store store, @Nonnull Ref 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 store, + @Nonnull Ref 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; + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java index 39ca1fa..454187f 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java @@ -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: *
    - *
  • Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for - * attack speed, which is otherwise client-authoritative),
  • + *
  • Archer "Tir rapide": x4 bow shot rate (250 ms between clicks),
  • *
  • Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,
  • *
  • Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,
  • *
  • Assassin "Camouflage": bookkeeping of the current invisibility window.
  • diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java index d0e8008..779fbf9 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java @@ -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 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()); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgAttackSpeedSystem.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgAttackSpeedSystem.java new file mode 100644 index 0000000..60042e8 --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgAttackSpeedSystem.java @@ -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 { + + private final PlayerSessionService sessions; + private final CharacterStatService characterStats; + private final ComponentType interactionManagerType; + + public MmorpgAttackSpeedSystem( + @Nonnull PlayerSessionService sessions, + @Nonnull CharacterStatService characterStats) { + this.sessions = sessions; + this.characterStats = characterStats; + this.interactionManagerType = InteractionModule.get().getInteractionManagerComponent(); + } + + @Override + public Query getQuery() { + return Query.and(interactionManagerType, PlayerRef.getComponentType()); + } + + @Override + public Set> getDependencies() { + return Set.of(new SystemDependency<>( + Order.BEFORE, + InteractionSystems.TickInteractionManagerSystem.class)); + } + + @Override + public void tick( + float dt, + int index, + @Nonnull ArchetypeChunk chunk, + @Nonnull Store store, + @Nonnull CommandBuffer 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); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java index 88c18cb..5a1f959 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java @@ -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 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 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 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 store, @Nonnull Ref ref) { + @Nullable + private UUID playerUuid(@Nonnull Store store, @Nonnull Ref 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 store, @Nonnull Ref ref) { + return playerUuid(store, ref); + } + + private static boolean isArcherBowHit( + @Nullable PlayerProfile profile, + @Nonnull Store store, + @Nonnull Ref 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"); + } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgPlayerDeathSystem.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgPlayerDeathSystem.java new file mode 100644 index 0000000..48b772e --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgPlayerDeathSystem.java @@ -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 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 getQuery() { + return QUERY; + } + + @Override + public void onComponentAdded( + @Nonnull Ref deadRef, + @Nonnull DeathComponent death, + @Nonnull Store store, + @Nonnull CommandBuffer 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); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgPlayerRespawnSystem.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgPlayerRespawnSystem.java new file mode 100644 index 0000000..a35e69a --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgPlayerRespawnSystem.java @@ -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 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 getQuery() { + return QUERY; + } + + @Override + public void onComponentRemoved( + @Nonnull Ref ref, + @Nonnull DeathComponent death, + @Nonnull Store store, + @Nonnull CommandBuffer 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); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java index c68325c..edde461 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java @@ -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 store, + @Nonnull Ref 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) { diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/HudCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/HudCommand.java index e7e573c..28d5a11 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/HudCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/HudCommand.java @@ -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.")); } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java index 9ed3f6b..9e0485f 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java @@ -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))); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java index 9b9c640..8fb6c2d 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java @@ -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 [classe] + /mmorpg class [classe] /mmorpg power [pouvoir] /mmorpg job [metier] /mmorpg group [joueur] diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java index e2f8602..668bf84 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java @@ -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 entityRef = playerRef.getReference(); - if (session != null - && session.activeCharacter() != null - && online != null - && entityRef != null - && entityRef.isValid()) { - Store 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 entityRef, - @Nonnull Store 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 store, diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/interaction/MmorpgArcherShootInteraction.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/interaction/MmorpgArcherShootInteraction.java new file mode 100644 index 0000000..7847c48 --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/interaction/MmorpgArcherShootInteraction.java @@ -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 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 entityRef = interactionContext.getEntity(); + if (entityRef == null || !entityRef.isValid()) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + Store 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); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java index 49be335..7dbe149 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java @@ -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 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; } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java index 1a7ddc5..ec98f3e 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java @@ -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() { } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java index ef09f76..389a581 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java @@ -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 holder) throws SQLException { + repository.replaceAll(characterId, captureFromHolder(holder)); + } + public void loadToPlayer( @Nonnull UUID characterId, @Nonnull Store store, @@ -55,34 +65,54 @@ public final class CharacterInventoryService { @Nonnull Ref ref) { List 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 captureFromHolder(@Nonnull Holder holder) { + List 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 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 slots, @Nonnull Store 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 store, @Nonnull Ref 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); } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java index 8d85d1d..33ed725 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java @@ -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 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 ref, + @Nonnull Store store, + @Nonnull UUID accountUuid) { + try { + List 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 ")); + } + } + public void openCharacterSelector( @Nonnull PlayerRef playerRef, @Nonnull Ref 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 ref, + @Nonnull Store 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 store, @Nonnull Ref ref) { + persistActiveCharacterInternal(session, characterId -> + inventory.saveFromPlayer(characterId, store, ref)); + } + + public void persistActiveCharacter( + @Nonnull PlayerSessionService.AccountSession session, + @Nonnull PlayerRef playerRef) { + Ref ref = playerRef.getReference(); + if (ref != null && ref.isValid()) { + persistActiveCharacter(session, ref.getStore(), ref); + return; + } + + Holder 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 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 ref, + @Nonnull Store 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 ref, + @Nonnull Store 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 ref, + @Nonnull Store 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 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 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 ")); + 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 ")); + 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>")); } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java index 4ad7347..9d5a95a 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java @@ -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")); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java index 2ddb9fb..8150137 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java @@ -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 sessions = new ConcurrentHashMap<>(); private final Map connectedAt = new ConcurrentHashMap<>(); + private final Map characterActiveSince = new ConcurrentHashMap<>(); + private final Set pendingSelectorAfterRespawn = ConcurrentHashMap.newKeySet(); + private final Map 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) { diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java index 5494eb2..82b768c 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java @@ -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()); } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java index f60d385..10bd9b8 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java @@ -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); + } } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java index 44bd9ae..fca6ecd 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java @@ -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 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 BY_ID = new LinkedHashMap<>(); static { @@ -116,4 +124,9 @@ public final class ClassCatalog { public static List all() { return List.copyOf(BY_ID.values()); } + + @Nullable + public static String weaponItemId(@Nonnull String weaponKey) { + return WEAPON_ITEM_IDS.get(weaponKey); + } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassSelectionSupport.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassSelectionSupport.java new file mode 100644 index 0000000..9df666f --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassSelectionSupport.java @@ -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 store, + @Nonnull Ref 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; + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassWeaponService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassWeaponService.java new file mode 100644 index 0000000..36474b1 --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassWeaponService.java @@ -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 store, + @Nonnull Ref 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 store, + @Nonnull Ref 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 store, + @Nonnull Ref 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 store, + @Nonnull Ref 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; + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java index 1a818af..a590fa9 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java @@ -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)); + } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/stats/CharacterStat.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/stats/CharacterStat.java new file mode 100644 index 0000000..151339f --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/stats/CharacterStat.java @@ -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; + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/stats/CharacterStatService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/stats/CharacterStatService.java new file mode 100644 index 0000000..96f694a --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/rpg/stats/CharacterStatService.java @@ -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: + *
      + *
    • Force — +2 flat physical damage per point
    • + *
    • Vitalité — +10 max health per point
    • + *
    • Dextérité — +1% attack speed per point (weapon swing rate + shorter ability cooldowns)
    • + *
    • Vitesse — +2 movement speed per point
    • + *
    + * 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 store, + @Nonnull Ref 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 store, + @Nonnull Ref 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 store, + @Nonnull Ref 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 store, + @Nonnull Ref 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()); + } + } + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java index f76f094..fcb8e0c 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java @@ -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; diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java index 2f91027..b7b12f9 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java @@ -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 { @@ -142,6 +145,10 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage { + + 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 onChosen; + + public ClassSelectPage( + @Nonnull MmorpgPlugin plugin, + @Nonnull PlayerRef playerRef, + @Nonnull PlayerProfile profile, + boolean mandatory, + boolean welcomeAfterPick, + @Nullable Consumer 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 ref, + @Nonnull UICommandBuilder ui, + @Nonnull UIEventBuilder events, + @Nonnull Store 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 ref, + @Nonnull Store 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 ref, + @Nonnull Store 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 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 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() { + } + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerInventoryPage.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerInventoryPage.java index 0f9a064..6e7f2ca 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerInventoryPage.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerInventoryPage.java @@ -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 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; }; diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerMenuPage.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerMenuPage.java index 9a74dd1..c0d6274 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerMenuPage.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/PlayerMenuPage.java @@ -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. *

    * 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 { 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 ref, @Nonnull Store 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 { 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 store, @Nonnull Ref ref) { PlayerClass playerClass = ClassCatalog.byId(profile.getClassId()); @@ -194,11 +231,6 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage 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 "Personnage"; case INVENTORY -> "Inventaire"; + case STATS -> "Statistiques"; case SKILLS -> "Compétences"; }; } @@ -323,6 +417,9 @@ public final class PlayerMenuPage extends InteractiveCustomUIPage 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 devserver/mods/" + done + shopt -u nullglob +} + +apply_world_spawn() { + local world_config="$ROOT/devserver/universe/worlds/default/config.json" + local spawn_template="$ROOT/config/universe/worlds/default/spawn-provider.json" + local players_dir="$ROOT/devserver/universe/players" + + [[ -f "$spawn_template" ]] || return 0 + [[ -f "$world_config" ]] || return 0 + + python3 - "$spawn_template" "$world_config" "$players_dir" <<'PY' +import json +import sys +from pathlib import Path + +spawn_template = Path(sys.argv[1]) +world_config = Path(sys.argv[2]) +players_dir = Path(sys.argv[3]) + +patch = json.loads(spawn_template.read_text(encoding="utf-8")) +config = json.loads(world_config.read_text(encoding="utf-8")) +config.update(patch) +world_config.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + +spawn = patch["SpawnProvider"]["SpawnPoint"] +last_position = { + "X": spawn["X"], + "Y": spawn["Y"], + "Z": spawn["Z"], + "Pitch": spawn.get("Pitch", 0.0), + "Yaw": spawn.get("Yaw", 0.0), + "Roll": spawn.get("Roll", 0.0), +} +position = {"X": spawn["X"], "Y": spawn["Y"], "Z": spawn["Z"]} +rotation = { + "Pitch": spawn.get("Pitch", 0.0), + "Yaw": spawn.get("Yaw", 0.0), + "Roll": spawn.get("Roll", 0.0), +} + +if players_dir.is_dir(): + for player_file in players_dir.glob("*.json"): + data = json.loads(player_file.read_text(encoding="utf-8")) + components = data.get("Components", {}) + transform = components.get("Transform") + if isinstance(transform, dict): + transform["Position"] = dict(position) + transform["Rotation"] = dict(rotation) + + player_data = components.get("Player", {}).get("PlayerData", {}) + per_world = player_data.get("PerWorldData", {}).get("default") + if isinstance(per_world, dict): + per_world["LastPosition"] = dict(last_position) + + player_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + +print( + f"Spawn default: X={spawn['X']}, Y={spawn['Y']}, Z={spawn['Z']} " + f"(world config + joueurs existants)" +) +PY +} + run_gradle() { "$ROOT/gradlew" --stop 2>/dev/null || true "$ROOT/gradlew" "${GRADLE_ARGS[@]}" "$@" @@ -116,9 +193,12 @@ reset_devserver_if_needed echo "Configuration du serveur de dev..." run_gradle setupServer +sync_third_party_plugins echo "Compilation..." run_gradle build +apply_world_spawn + echo "Démarrage du serveur (logs: devserver/logs/)..." exec "$ROOT/gradlew" "${GRADLE_ARGS[@]}" runServer diff --git a/Webapp/public/css/style.css b/Webapp/public/css/style.css index 1d1b68e..bba907f 100644 --- a/Webapp/public/css/style.css +++ b/Webapp/public/css/style.css @@ -281,6 +281,21 @@ code { color: #94a3b8; } +.tag-alive { + background: rgba(74, 222, 128, 0.15); + color: var(--success); +} + +.tag-dead { + background: rgba(248, 113, 113, 0.15); + color: var(--danger); +} + +.death-date { + display: block; + margin-top: 0.2rem; +} + .link { color: var(--accent); text-decoration: none; diff --git a/Webapp/server.js b/Webapp/server.js index 2c668a8..eaa4215 100644 --- a/Webapp/server.js +++ b/Webapp/server.js @@ -140,6 +140,8 @@ function enrichPlayer(player) { ); const jobs = labels.parseJsonArray(player.jobs).map(labels.labelJob); const powers = labels.parseJsonArray(player.powers).map(labels.labelPower); + const deathTime = player.death_time ?? null; + const isDead = deathTime != null && Number(deathTime) > 0; return { ...player, @@ -148,6 +150,10 @@ function enrichPlayer(player) { jobs, powers, isOnline: player.is_connected === 1, + isDead, + lifeStatus: isDead ? "Mort" : "Vivant", + deathTimeFormatted: isDead ? fmt.formatTimestamp(deathTime) : null, + total_time_play: player.total_time_play ?? 0, }; } diff --git a/Webapp/src/db.js b/Webapp/src/db.js index 24376ac..f9bc59d 100644 --- a/Webapp/src/db.js +++ b/Webapp/src/db.js @@ -108,7 +108,9 @@ function getCharactersByAccountUuid(accountUuid) { money, race_id, date_creation, - updated_at + updated_at, + death_time, + total_time_play FROM characters WHERE account_uuid = ? ORDER BY name COLLATE NOCASE ASC @@ -225,7 +227,9 @@ function getAllPlayers() { c.race_id, c.money, p.is_connected, - p.total_time_play, + c.total_time_play, + p.total_time_play AS account_total_time_play, + c.death_time, p.last_date_connected, c.date_creation FROM characters c @@ -266,7 +270,9 @@ function getPlayerByUuid(uuid) { c.guild_id, c.group_id, p.is_connected, - p.total_time_play, + COALESCE(c.total_time_play, 0) AS total_time_play, + p.total_time_play AS account_total_time_play, + c.death_time, p.last_date_connected, COALESCE(c.date_creation, p.date_creation) AS date_creation, COALESCE(c.money, 0) AS money, diff --git a/Webapp/src/format.js b/Webapp/src/format.js index 9153fd8..1a1cd87 100644 --- a/Webapp/src/format.js +++ b/Webapp/src/format.js @@ -20,6 +20,20 @@ function formatDuration(seconds) { return `${hours} h ${minutes} min`; } +/** Play time stored in milliseconds (characters.total_time_play). */ +function formatPlayTime(millis) { + if (!millis || millis <= 0) { + return "0 min"; + } + const totalSeconds = Math.floor(millis / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (hours === 0) { + return `${minutes} min`; + } + return `${hours} h ${minutes} min`; +} + function formatMoney(value) { return new Intl.NumberFormat("fr-FR").format(value ?? 0); } @@ -31,6 +45,7 @@ function formatNumber(value) { module.exports = { formatTimestamp, formatDuration, + formatPlayTime, formatMoney, formatNumber, }; diff --git a/Webapp/views/players/list.ejs b/Webapp/views/players/list.ejs index 83f532d..50d0f47 100644 --- a/Webapp/views/players/list.ejs +++ b/Webapp/views/players/list.ejs @@ -32,13 +32,14 @@ <%= p.raceLabel %> <%= fmt.formatMoney(p.money) %> - <% if (p.isOnline) { %> - En ligne + <% if (p.isDead) { %> + Mort + <%= p.deathTimeFormatted %> <% } else { %> - Hors ligne + Vivant <% } %> - <%= fmt.formatDuration(p.total_time_play) %> + <%= fmt.formatPlayTime(p.total_time_play) %> <% }); %>