first
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-05 15:00:58 +02:00
commit 3710a28ae0
100 changed files with 7744 additions and 0 deletions
@@ -0,0 +1,747 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.entity.UUIDComponent;
import com.hypixel.hytale.server.core.modules.entity.component.HeadRotation;
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.ItemStack;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
import com.hypixel.hytale.server.core.modules.physics.component.Velocity;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.server.core.util.TargetUtil;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Executes the active class abilities. Effects are applied with the verified server APIs
* ({@link TargetUtil}, {@link DamageSystems#executeDamage}, {@link Velocity}); exact numbers
* (dash speed, radii, damage) are tuned via {@code config.json}-style constants and may need
* in-game adjustment.
*/
public final class AbilityService {
// Knockback impulse for the forward dash. The vertical component matters a lot: too little and
// ground friction kills the lunge instantly (the previous ~0.5 block issue). Tunable.
private static final double DASH_KNOCKBACK = 16.0;
private static final double DASH_KNOCKBACK_Y = 4.0;
private static final double DASH_RADIUS = 2.5;
private static final long DASH_SCAN_PERIOD = 100L;
private static final int DASH_SCANS = 5;
private static final long STUN_DURATION = 3_000L;
private static final double SPIN_RADIUS = 3.0;
private static final float SPIN_DAMAGE = 3.0f;
private static final long SPIN_DURATION = 3_000L;
private static final long SPIN_PERIOD = 300L;
private static final double SHOUT_RADIUS = 5.0;
private static final double SHOUT_KNOCKBACK = 18.0;
private static final double SHOUT_KNOCKBACK_Y = 4.0;
private static final float SHOUT_STUNNED_BONUS_DAMAGE = 6.0f;
private static final long RESISTANCE_DEBUFF_DURATION = 20_000L;
// --- Archer ---
private static final long RAPID_FIRE_DURATION = 10_000L;
private static final int VOLLEY_ARROWS = 6;
private static final double VOLLEY_SPREAD_DEG = 9.0;
private static final float VOLLEY_ARROW_DAMAGE = 4.0f;
private static final double VOLLEY_RANGE = 18.0;
private static final double VOLLEY_BACK_KNOCKBACK = 12.0;
private static final double VOLLEY_BACK_KNOCKBACK_Y = 3.0;
// --- Mage ---
private static final int FIREBALL_COUNT = 3;
private static final double FIREBALL_SPREAD_DEG = 12.0;
private static final float FIREBALL_DAMAGE = 7.0f;
private static final double FIREBALL_RANGE = 20.0;
private static final double FIREBALL_HIT_RADIUS = 1.6;
private static final double TELEPORT_DISTANCE = 8.0;
private static final double TORNADO_DISTANCE = 15.0;
private static final double TORNADO_RADIUS = 3.5;
private static final float TORNADO_DAMAGE = 4.0f;
private static final double TORNADO_PULL = 6.0;
private static final long TORNADO_DURATION = 4_000L;
private static final long TORNADO_PERIOD = 350L;
// --- Assassin ---
private static final long CAMOUFLAGE_DURATION = 6_000L;
private static final double SHADOWSTEP_RANGE = 12.0;
private static final double SHADOWSTEP_BEHIND = 1.5;
private static final long POISON_BLADE_DURATION = 10_000L;
// Projectile / spell visuals (shipped game particle systems; unknown ids fail silently).
private static final String VFX_ARROW_TRAIL = "Bow_Signature_Projectile_Sparks";
private static final String VFX_ARROW_HIT = "Example_Projectile_Impact";
private static final String VFX_FIRE_TRAIL = "Fire_Charged1";
private static final String VFX_FIRE_HIT = "Impact_Explosion";
private static final String VFX_TELEPORT = "Teleport";
private static final String VFX_TORNADO = "Example_Tornado";
private static final String VFX_TORNADO_HIT = "Impact_Battleaxe_Bash";
private static final String VFX_SMOKE = "Smoke_Black";
private static final String VFX_BUFF = "Test_Cast_Buff";
private static final String SFX_BOW = "SFX_Bow_T1_Shoot";
private static final String SFX_FIRE = "SFX_Staff_Flame_Fireball_Launch";
private static final String SFX_TELEPORT = "SFX_Portal_Neutral";
// Visual / audio asset ids (shipped game assets). See CombatFx.
private static final String VFX_DASH = "Daggers_Signature_Dash";
private static final String VFX_HIT = "Impact_Blade_01";
private static final String VFX_SPIN = "Battleaxe_Signature_Whirlwind";
private static final String VFX_SPIN_HIT = "Impact_Battleaxe_Bash";
private static final String VFX_SHOUT = "Impact_Explosion";
private static final String SFX_ACTIVATE = "SFX_Avatar_Powers_Enable";
private static final String SFX_SWING = "SFX_Golem_Earth_Swing";
private static final String SFX_HIT = "SFX_Golem_Earth_Swing_Impact";
private static final String SFX_SHOUT = "SFX_Deer_Stag_Roar";
private static final String SFX_SHOUT_IMPACT = "SFX_Golem_Earth_Slam_Impact";
// Weapon animation moves (see Server/Item/Animations/{Battleaxe,Longsword}.json).
private static final String ANIM_SET_BATTLEAXE = "Battleaxe";
private static final String ANIM_SET_LONGSWORD = "Longsword";
private static final String ANIM_DASH = "SwingDown";
private static final String ANIM_SHOUT = "GuardBash";
private static final String ANIM_SPIN_BATTLEAXE = "WhirlwindChargedSpin";
private static final String ANIM_SPIN_LONGSWORD_A = "SwingLeft";
private static final String ANIM_SPIN_LONGSWORD_B = "SwingRight";
private final MmorpgScheduler scheduler;
private final StunService stunService;
private final CombatStateService combatState;
private final CombatBuffService combatBuffs;
private final Map<UUID, Map<String, Long>> cooldowns = new ConcurrentHashMap<>();
public AbilityService(
@Nonnull MmorpgScheduler scheduler,
@Nonnull StunService stunService,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs) {
this.scheduler = scheduler;
this.stunService = stunService;
this.combatState = combatState;
this.combatBuffs = combatBuffs;
}
public long cooldownRemaining(@Nonnull UUID player, @Nonnull String abilityId) {
Map<String, Long> map = cooldowns.get(player);
if (map == null) {
return 0L;
}
Long readyAt = map.get(abilityId);
if (readyAt == null) {
return 0L;
}
return Math.max(0L, readyAt - System.currentTimeMillis());
}
/**
* Attempts to cast {@code ability} for the player. The caller is responsible for verifying the
* player owns the class; this method enforces the weapon requirement and the cooldown.
*/
@Nonnull
public CastResult cast(
@Nonnull PlayerRef playerRef,
@Nonnull World world,
@Nonnull PlayerClass playerClass,
@Nonnull AbilityDefinition ability) {
return cast(playerRef, world, playerClass, ability, null);
}
/**
* Casts {@code ability}. {@code aimedTarget} is the entity the player is currently looking at
* (supplied by the input-action handler, used by abilities such as the assassin's Shadow Step);
* it may be {@code null} when triggered from the command, in which case the target is resolved
* from the caster's aim.
*/
@Nonnull
public CastResult cast(
@Nonnull PlayerRef playerRef,
@Nonnull World world,
@Nonnull PlayerClass playerClass,
@Nonnull AbilityDefinition ability,
@Nullable Ref<EntityStore> aimedTarget) {
UUID caster = playerRef.getUuid();
Store<EntityStore> store = world.getEntityStore().getStore();
Ref<EntityStore> casterRef = playerRef.getReference();
if (!hasRequiredWeapon(store, casterRef, playerClass)) {
return CastResult.WRONG_WEAPON;
}
if (cooldownRemaining(caster, ability.id()) > 0) {
return CastResult.ON_COOLDOWN;
}
String weaponSet = resolveWeaponAnimSet(store, casterRef);
switch (ability.id()) {
case ClassCatalog.ABILITY_DASH -> castDash(world, store, casterRef, weaponSet);
case ClassCatalog.ABILITY_SPIN -> castSpin(world, store, casterRef, weaponSet);
case ClassCatalog.ABILITY_SHOUT -> castShout(world, store, casterRef, weaponSet);
case ClassCatalog.ABILITY_ARCHER_RAPIDFIRE -> castRapidFire(world, store, casterRef, caster);
case ClassCatalog.ABILITY_ARCHER_VOLLEY -> castVolley(world, store, casterRef);
case ClassCatalog.ABILITY_ARCHER_POWERSHOT -> castPowerShot(world, store, casterRef, caster);
case ClassCatalog.ABILITY_MAGE_FIREBALLS -> castFireballs(world, store, casterRef);
case ClassCatalog.ABILITY_MAGE_TELEPORT -> castTeleport(world, store, casterRef);
case ClassCatalog.ABILITY_MAGE_TORNADO -> castTornado(world, store, casterRef);
case ClassCatalog.ABILITY_ASSASSIN_CAMOUFLAGE -> castCamouflage(world, store, casterRef, playerRef);
case ClassCatalog.ABILITY_ASSASSIN_SHADOWSTEP -> castShadowStep(world, store, casterRef, aimedTarget);
case ClassCatalog.ABILITY_ASSASSIN_POISONBLADE -> castPoisonBlade(world, store, casterRef, caster);
default -> {
return CastResult.UNKNOWN;
}
}
combatState.markCombat(caster);
cooldowns.computeIfAbsent(caster, k -> new ConcurrentHashMap<>())
.put(ability.id(), System.currentTimeMillis() + ability.cooldownMillis());
return CastResult.OK;
}
/**
* Maps the currently held weapon to its animation set id ({@code Battleaxe} / {@code Longsword}),
* so we can trigger the matching swing/whirlwind move. Returns {@code null} if it cannot be
* resolved (the animation step is then skipped, effects still play).
*/
private String resolveWeaponAnimSet(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
try {
ItemStack held = InventoryComponent.getItemInHand(store, casterRef);
if (ItemStack.isEmpty(held)) {
return null;
}
String itemId = held.getItemId();
if (itemId == null) {
return null;
}
String lower = itemId.toLowerCase();
if (lower.contains("battleaxe") || (lower.contains("axe") && !lower.contains("pickaxe"))) {
return ANIM_SET_BATTLEAXE;
}
return ANIM_SET_LONGSWORD;
} catch (Throwable t) {
return null;
}
}
private boolean hasRequiredWeapon(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull PlayerClass playerClass) {
try {
ItemStack held = InventoryComponent.getItemInHand(store, casterRef);
if (ItemStack.isEmpty(held)) {
return false;
}
String itemId = held.getItemId();
if (itemId == null) {
return false;
}
for (String weapon : playerClass.weapons()) {
if (itemId.toLowerCase().contains(weapon.toLowerCase())) {
return true;
}
}
return false;
} catch (Throwable t) {
// If the inventory API shape differs at runtime, fail open so the ability is still usable.
return true;
}
}
private void castDash(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
String weaponSet) {
world.execute(() -> {
Vector3d direction = horizontalLookDirection(store, casterRef);
// Player movement is client-authoritative, so push the caster via the engine knockback
// system (syncs to the client) instead of writing Velocity directly.
CombatFx.knockback(store, casterRef, new Vector3d(
direction.x * DASH_KNOCKBACK, DASH_KNOCKBACK_Y, direction.z * DASH_KNOCKBACK));
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
Vector3d pos = transform != null ? transform.getPosition() : new Vector3d();
CombatFx.animation(store, casterRef, weaponSet, ANIM_DASH);
CombatFx.vfx(store, VFX_DASH, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.2f);
CombatFx.sound(store, SFX_SWING, pos);
});
// Stun every enemy the dash passes through over its travel window.
for (int scan = 1; scan <= DASH_SCANS; scan++) {
scheduler.runLater(() -> world.execute(() -> {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform == null) {
return;
}
Vector3d pos = transform.getPosition();
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(
pos, DASH_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
applyStun(world, store, target);
dealDamage(store, casterRef, target, 2.0f);
CombatFx.vfx(store, VFX_HIT, targetPosition(store, target, pos));
CombatFx.sound(store, SFX_HIT, pos);
}
}), (long) scan * DASH_SCAN_PERIOD);
}
}
private void castSpin(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
String weaponSet) {
String spinAnimA = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_A;
String spinAnimB = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_B;
java.util.concurrent.atomic.AtomicInteger tick = new java.util.concurrent.atomic.AtomicInteger();
var future = scheduler.runRepeating(() -> world.execute(() -> {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform == null) {
return;
}
Vector3d pos = transform.getPosition();
int n = tick.getAndIncrement();
// Keep the whirlwind spinning visually and audibly for its whole duration. Each puff is
// spawned with a short, explicit lifetime so it follows the player and fully disappears
// when the spin ends (otherwise the looping aura would stay on screen forever).
CombatFx.animation(store, casterRef, weaponSet, (n % 2 == 0) ? spinAnimA : spinAnimB);
CombatFx.vfxTimed(store, VFX_SPIN, pos, 1.0f, (SPIN_PERIOD / 1000.0f) * 1.2f);
CombatFx.sound(store, SFX_SWING, pos, 0.8f, 1.1f);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(pos, SPIN_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
dealDamage(store, casterRef, target, SPIN_DAMAGE);
CombatFx.vfx(store, VFX_SPIN_HIT, targetPosition(store, target, pos));
}
}), 0L, SPIN_PERIOD);
scheduler.runLater(() -> future.cancel(false), SPIN_DURATION);
}
private void castShout(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
String weaponSet) {
world.execute(() -> {
TransformComponent casterTransform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (casterTransform == null) {
return;
}
Vector3d origin = casterTransform.getPosition();
// Fallback push direction (caster's facing) for targets standing right on top of the
// player, where origin-to-target has no usable direction.
Vector3d facing = horizontalLookDirection(store, casterRef);
CombatFx.animation(store, casterRef, weaponSet, ANIM_SHOUT);
CombatFx.vfx(store, VFX_SHOUT, origin);
CombatFx.sound(store, SFX_SHOUT, origin, 1.2f, 0.9f);
CombatFx.sound(store, SFX_SHOUT_IMPACT, origin);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(origin, SHOUT_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
TransformComponent targetTransform =
store.getComponent(target, TransformComponent.getComponentType());
if (targetTransform != null) {
Vector3d push = new Vector3d(targetTransform.getPosition()).sub(origin);
push.y = 0;
if (push.lengthSquared() < 1.0e-4) {
// Mob glued to the player: shove it away along the caster's look direction.
push.set(facing.x, 0, facing.z);
}
if (push.lengthSquared() < 1.0e-4) {
push.set(0, 0, 1);
}
push.normalize();
// Client-synced knockback so both players and mobs are actually pushed back.
CombatFx.knockback(store, target, new Vector3d(
push.x * SHOUT_KNOCKBACK, SHOUT_KNOCKBACK_Y, push.z * SHOUT_KNOCKBACK));
}
UUID targetUuid = entityUuid(store, target);
if (targetUuid != null) {
if (stunService.isStunned(targetUuid)) {
dealDamage(store, casterRef, target, SHOUT_STUNNED_BONUS_DAMAGE);
}
// -20% resistance == takes +20% damage for 20s (applied in the damage system).
combatState.applyResistanceDebuff(targetUuid, RESISTANCE_DEBUFF_DURATION);
}
}
});
}
// ------------------------------------------------------------------------------------------
// Archer
// ------------------------------------------------------------------------------------------
private void castRapidFire(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull UUID caster) {
combatBuffs.grantRapidFire(caster, RAPID_FIRE_DURATION);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, VFX_BUFF, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.4f);
});
}
private void castVolley(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
world.execute(() -> {
Vector3d facing = horizontalLookDirection(store, casterRef);
// Bond en arrière.
CombatFx.knockback(store, casterRef, new Vector3d(
-facing.x * VOLLEY_BACK_KNOCKBACK, VOLLEY_BACK_KNOCKBACK_Y, -facing.z * VOLLEY_BACK_KNOCKBACK));
Vector3d eye = eyePosition(store, casterRef);
CombatFx.sound(store, SFX_BOW, eye, 1.0f, 1.1f);
double start = -(VOLLEY_ARROWS - 1) / 2.0;
for (int i = 0; i < VOLLEY_ARROWS; i++) {
Vector3d dir = rotatedHorizontal(facing, (start + i) * VOLLEY_SPREAD_DEG);
fireProjectile(store, casterRef, eye, dir, VOLLEY_RANGE, 1.2,
VOLLEY_ARROW_DAMAGE, VFX_ARROW_TRAIL, VFX_ARROW_HIT);
}
});
}
private void castPowerShot(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull UUID caster) {
combatBuffs.chargePowerShot(caster);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, VFX_BUFF, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.8f);
});
}
// ------------------------------------------------------------------------------------------
// Mage
// ------------------------------------------------------------------------------------------
private void castFireballs(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
world.execute(() -> {
Vector3d facing = horizontalLookDirection(store, casterRef);
Vector3d eye = eyePosition(store, casterRef);
CombatFx.sound(store, SFX_FIRE, eye, 1.1f, 1.0f);
double start = -(FIREBALL_COUNT - 1) / 2.0;
for (int i = 0; i < FIREBALL_COUNT; i++) {
Vector3d dir = rotatedHorizontal(facing, (start + i) * FIREBALL_SPREAD_DEG);
fireProjectile(store, casterRef, eye, dir, FIREBALL_RANGE, FIREBALL_HIT_RADIUS,
FIREBALL_DAMAGE, VFX_FIRE_TRAIL, VFX_FIRE_HIT);
}
});
}
private void castTeleport(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
world.execute(() -> {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform == null) {
return;
}
Vector3d from = new Vector3d(transform.getPosition());
Vector3d dir = horizontalLookDirection(store, casterRef);
Vector3d to = new Vector3d(from).add(dir.x * TELEPORT_DISTANCE, 0, dir.z * TELEPORT_DISTANCE);
CombatFx.vfx(store, VFX_TELEPORT, from);
CombatFx.sound(store, SFX_TELEPORT, from, 1.0f, 1.0f);
transform.teleportPosition(to);
CombatFx.vfx(store, VFX_TELEPORT, to);
CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.2f);
});
}
private void castTornado(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
Vector3d[] center = new Vector3d[1];
world.execute(() -> {
Vector3d eye = eyePosition(store, casterRef);
Vector3d dir = horizontalLookDirection(store, casterRef);
center[0] = new Vector3d(eye).add(dir.x * TORNADO_DISTANCE, -1.0, dir.z * TORNADO_DISTANCE);
CombatFx.sound(store, SFX_FIRE, center[0], 1.3f, 0.7f);
});
var future = scheduler.runRepeating(() -> world.execute(() -> {
if (center[0] == null) {
return;
}
Vector3d pos = center[0];
CombatFx.vfxTimed(store, VFX_TORNADO, pos, 1.5f, (TORNADO_PERIOD / 1000.0f) * 1.3f);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(pos, TORNADO_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
Vector3d tpos = targetPosition(store, target, pos);
Vector3d pull = new Vector3d(pos).sub(tpos);
pull.y = 0;
if (pull.lengthSquared() > 1.0e-4) {
pull.normalize();
}
CombatFx.knockback(store, target, new Vector3d(
pull.x * TORNADO_PULL, 2.5, pull.z * TORNADO_PULL));
dealDamage(store, casterRef, target, TORNADO_DAMAGE);
CombatFx.vfx(store, VFX_TORNADO_HIT, tpos);
}
}), 0L, TORNADO_PERIOD);
scheduler.runLater(() -> future.cancel(false), TORNADO_DURATION);
}
// ------------------------------------------------------------------------------------------
// Assassin
// ------------------------------------------------------------------------------------------
private void castCamouflage(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull PlayerRef playerRef) {
UUID caster = playerRef.getUuid();
combatBuffs.markInvisible(caster, CAMOUFLAGE_DURATION);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, VFX_SMOKE, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.7f);
for (PlayerRef viewer : world.getPlayerRefs()) {
try {
viewer.getHiddenPlayersManager().hidePlayer(caster);
} catch (Throwable ignored) {
// viewer not ready: skip.
}
}
});
scheduler.runLater(() -> world.execute(() -> {
for (PlayerRef viewer : world.getPlayerRefs()) {
try {
viewer.getHiddenPlayersManager().showPlayer(caster);
} catch (Throwable ignored) {
// viewer gone: skip.
}
}
CombatFx.vfx(store, VFX_SMOKE, position(store, casterRef));
}), CAMOUFLAGE_DURATION);
}
private void castShadowStep(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nullable Ref<EntityStore> aimedTarget) {
world.execute(() -> {
Ref<EntityStore> target = aimedTarget;
if (target == null || !target.isValid() || target.equals(casterRef)) {
target = TargetUtil.getTargetEntity(casterRef, (float) SHADOWSTEP_RANGE, store);
}
TransformComponent casterTransform =
store.getComponent(casterRef, TransformComponent.getComponentType());
if (casterTransform == null) {
return;
}
if (target == null || !target.isValid() || target.equals(casterRef)) {
// No target in sight: short blink forward instead of failing outright.
Vector3d dir = horizontalLookDirection(store, casterRef);
Vector3d from = new Vector3d(casterTransform.getPosition());
Vector3d to = new Vector3d(from).add(dir.x * 3.0, 0, dir.z * 3.0);
CombatFx.vfx(store, VFX_TELEPORT, from);
casterTransform.teleportPosition(to);
CombatFx.vfx(store, VFX_TELEPORT, to);
CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.0f);
return;
}
TransformComponent targetTransform =
store.getComponent(target, TransformComponent.getComponentType());
if (targetTransform == null) {
return;
}
Vector3d targetPos = new Vector3d(targetTransform.getPosition());
Vector3d targetFacing = horizontalLookDirection(store, target);
Vector3d behind = new Vector3d(targetPos)
.sub(targetFacing.x * SHADOWSTEP_BEHIND, 0, targetFacing.z * SHADOWSTEP_BEHIND);
Vector3d from = new Vector3d(casterTransform.getPosition());
CombatFx.vfx(store, VFX_SMOKE, from);
casterTransform.teleportPosition(behind);
CombatFx.vfx(store, VFX_TELEPORT, behind);
CombatFx.sound(store, SFX_TELEPORT, behind, 1.0f, 0.9f);
});
}
private void castPoisonBlade(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull UUID caster) {
combatBuffs.grantPoisonBlade(caster, POISON_BLADE_DURATION);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, "Impact_Poison", pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.9f);
});
}
// ------------------------------------------------------------------------------------------
// Shared helpers for the new abilities
// ------------------------------------------------------------------------------------------
/**
* Casts a straight projectile by stepping along {@code dir} from {@code origin}, leaving a
* particle streak and damaging the first entity within {@code hitRadius} of the path. A
* server-side approximation of a real arrow/fireball (no engine projectile entity is spawned),
* but it reads as a projectile and deals damage.
*/
private void fireProjectile(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull Vector3d origin,
@Nonnull Vector3d dir,
double range,
double hitRadius,
float damage,
@Nonnull String trailVfx,
@Nonnull String hitVfx) {
Vector3d point = new Vector3d(origin);
Vector3d step = new Vector3d(dir).mul(1.0);
int steps = (int) Math.ceil(range);
for (int i = 0; i < steps; i++) {
point.add(step);
CombatFx.vfxTimed(store, trailVfx, new Vector3d(point), 0.5f, 0.35f);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(point, hitRadius, store)) {
if (target.equals(casterRef)) {
continue;
}
dealDamage(store, casterRef, target, damage);
CombatFx.vfx(store, hitVfx, targetPosition(store, target, point));
return;
}
}
}
@Nonnull
private Vector3d rotatedHorizontal(@Nonnull Vector3d base, double degrees) {
Vector3d v = new Vector3d(base);
v.y = 0;
v.rotateY((float) Math.toRadians(degrees));
if (v.lengthSquared() < 1.0e-4) {
return new Vector3d(0, 0, 1);
}
return v.normalize();
}
@Nonnull
private Vector3d eyePosition(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
return new Vector3d(position(store, ref)).add(0, 1.5, 0);
}
@Nonnull
private Vector3d position(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType());
return transform != null ? new Vector3d(transform.getPosition()) : new Vector3d();
}
private void applyStun(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> target) {
UUID uuid = entityUuid(store, target);
if (uuid != null) {
if (stunService.isStunned(uuid)) {
return;
}
stunService.stun(uuid, STUN_DURATION);
}
var future = scheduler.runRepeating(() -> world.execute(() -> {
Velocity velocity = store.getComponent(target, Velocity.getComponentType());
if (velocity != null) {
velocity.setZero();
}
}), 0L, 100L);
scheduler.runLater(() -> future.cancel(false), STUN_DURATION);
}
private void dealDamage(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull Ref<EntityStore> target,
float amount) {
Damage damage = new Damage(new Damage.EntitySource(casterRef), DamageCause.PHYSICAL, amount);
DamageSystems.executeDamage(target, store, damage);
}
@Nonnull
private Vector3d horizontalLookDirection(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
// Use the HEAD rotation (precise camera yaw), not the body TransformComponent rotation which
// is coarse/axis-snapped — that snapping made the dash align to X/Z instead of the true aim.
// This mirrors how the engine itself derives a knockback direction (see KnockbackApplyCommand).
Float yaw = null;
HeadRotation head = store.getComponent(casterRef, HeadRotation.getComponentType());
if (head != null && head.getRotation() != null) {
yaw = head.getRotation().yaw();
} else {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform != null && transform.getRotation() != null) {
yaw = transform.getRotation().yaw();
}
}
if (yaw == null) {
return new Vector3d(0, 0, 1);
}
Vector3d direction = new Vector3d(0, 0, -1).rotateY(yaw);
direction.y = 0;
if (direction.lengthSquared() < 1.0e-4) {
return new Vector3d(0, 0, 1);
}
return direction.normalize();
}
@Nonnull
private Vector3d targetPosition(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> target,
@Nonnull Vector3d fallback) {
TransformComponent transform = store.getComponent(target, TransformComponent.getComponentType());
return transform != null ? transform.getPosition() : fallback;
}
private UUID entityUuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
public enum CastResult {
OK, ON_COOLDOWN, WRONG_WEAPON, UNKNOWN
}
}
@@ -0,0 +1,119 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.bootstrap.Bootstrap;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Central entry point for routing Ability 1/2/3 inputs to MMORPG class abilities.
* Used by the packet filter, native {@code mmorpg_cast_ability} interaction, and commands.
*/
public final class ClassAbilityController {
private final MmorpgPlugin plugin;
public ClassAbilityController(@Nonnull MmorpgPlugin plugin) {
this.plugin = plugin;
}
/**
* @return {@code true} when the player has an MMORPG class and vanilla ability interactions
* should be suppressed for this slot (even if the cast fails, e.g. wrong weapon).
*/
public boolean shouldSuppressVanilla(@Nonnull PlayerRef playerRef, int slot) {
return resolveAbility(playerRef, slot) != null;
}
/**
* Attempts to cast the class ability bound to {@code slot}.
*
* @return {@code true} if vanilla should be blocked (player has a class with this slot).
*/
public boolean tryCast(
@Nonnull PlayerRef playerRef,
@Nonnull World world,
int slot,
@Nullable Ref<EntityStore> aimedTarget) {
ResolvedAbility resolved = resolveAbility(playerRef, slot);
if (resolved == null) {
return false;
}
AbilityService.CastResult result = resolved.abilities().cast(
playerRef, world, resolved.playerClass(), resolved.ability(), aimedTarget);
switch (result) {
case OK -> playerRef.sendMessage(Message.raw("Capacité : " + resolved.ability().displayName()));
case ON_COOLDOWN -> {
long remaining = resolved.abilities().cooldownRemaining(
playerRef.getUuid(), resolved.ability().id());
playerRef.sendMessage(Message.raw("En recharge (" + (remaining / 1000 + 1) + "s)."));
}
case WRONG_WEAPON -> playerRef.sendMessage(Message.raw(
"Mauvaise arme. Équipez : " + String.join(", ", resolved.playerClass().weapons())));
case UNKNOWN -> playerRef.sendMessage(Message.raw("Capacité inconnue."));
}
return true;
}
public static int slotFor(@Nonnull InteractionType type) {
return switch (type) {
case Ability1 -> 1;
case Ability2 -> 2;
case Ability3 -> 3;
default -> 0;
};
}
public static boolean isClassAbilityType(@Nonnull InteractionType type) {
return slotFor(type) != 0;
}
@Nullable
private ResolvedAbility resolveAbility(@Nonnull PlayerRef playerRef, int slot) {
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return null;
}
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
ClassService classService = bootstrap.getRegistry().require(ClassService.class);
AbilityService abilities = bootstrap.getRegistry().require(AbilityService.class);
PlayerProfile profile = sessions.getSession(playerRef.getUuid());
if (profile == null) {
return null;
}
PlayerClass playerClass = classService.getClass(profile);
if (playerClass == null) {
return null;
}
AbilityDefinition ability = playerClass.abilities().stream()
.filter(a -> a.slot() == slot)
.findFirst()
.orElse(null);
if (ability == null) {
return null;
}
return new ResolvedAbility(playerClass, ability, abilities);
}
private record ResolvedAbility(
PlayerClass playerClass,
AbilityDefinition ability,
AbilityService abilities) {
}
}
@@ -0,0 +1,72 @@
package com.disklexar.mmorpg.combat;
import javax.annotation.Nonnull;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks the temporary offensive buffs granted by class abilities and read back by the damage
* system when the buffed player lands a basic attack:
* <ul>
* <li>Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for
* attack speed, which is otherwise client-authoritative),</li>
* <li>Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,</li>
* <li>Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,</li>
* <li>Assassin "Camouflage": bookkeeping of the current invisibility window.</li>
* </ul>
*/
public final class CombatBuffService {
private final Map<UUID, Long> rapidFireUntil = new ConcurrentHashMap<>();
private final Set<UUID> powerShotCharged = ConcurrentHashMap.newKeySet();
private final Map<UUID, Long> poisonBladeUntil = new ConcurrentHashMap<>();
private final Map<UUID, Long> invisibleUntil = new ConcurrentHashMap<>();
public void grantRapidFire(@Nonnull UUID uuid, long durationMillis) {
rapidFireUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean hasRapidFire(@Nonnull UUID uuid) {
Long until = rapidFireUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void chargePowerShot(@Nonnull UUID uuid) {
powerShotCharged.add(uuid);
}
public boolean hasPowerShot(@Nonnull UUID uuid) {
return powerShotCharged.contains(uuid);
}
public boolean consumePowerShot(@Nonnull UUID uuid) {
return powerShotCharged.remove(uuid);
}
public void grantPoisonBlade(@Nonnull UUID uuid, long durationMillis) {
poisonBladeUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean hasPoisonBlade(@Nonnull UUID uuid) {
Long until = poisonBladeUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void markInvisible(@Nonnull UUID uuid, long durationMillis) {
invisibleUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean isInvisible(@Nonnull UUID uuid) {
Long until = invisibleUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void clear(@Nonnull UUID uuid) {
rapidFireUntil.remove(uuid);
powerShotCharged.remove(uuid);
poisonBladeUntil.remove(uuid);
invisibleUntil.remove(uuid);
}
}
@@ -0,0 +1,148 @@
package com.disklexar.mmorpg.combat;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.AnimationSlot;
import com.hypixel.hytale.protocol.ChangeVelocityType;
import com.hypixel.hytale.protocol.SoundCategory;
import com.hypixel.hytale.protocol.VelocityThresholdStyle;
import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent;
import com.hypixel.hytale.server.core.entity.AnimationUtils;
import com.hypixel.hytale.server.core.entity.knockback.KnockbackComponent;
import com.hypixel.hytale.server.core.modules.splitvelocity.VelocityConfig;
import com.hypixel.hytale.server.core.universe.world.ParticleUtil;
import com.hypixel.hytale.server.core.universe.world.SoundUtil;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Thin, failure-safe wrappers around the verified server effect helpers
* ({@link ParticleUtil}, {@link SoundUtil}, {@link AnimationUtils}). Each call is guarded so a
* missing asset id or a runtime API mismatch can never break the ability or disconnect the player —
* the worst case is simply that one effect is skipped.
* <p>
* Asset ids (particle systems / sound events / weapon animation moves) are taken from the shipped
* game assets, e.g. {@code Battleaxe_Signature_Whirlwind}, {@code Impact_Explosion},
* {@code SFX_Deer_Stag_Roar}, animation move {@code WhirlwindChargedSpin}.
*/
public final class CombatFx {
private CombatFx() {
}
/** Spawns a world particle system (broadcast to nearby players) at a position. */
public static void vfx(
@Nonnull Store<EntityStore> store,
@Nonnull String particleSystemId,
@Nonnull Vector3d position) {
try {
ParticleUtil.spawnParticleEffect(particleSystemId, new Vector3d(position), store);
} catch (Throwable ignored) {
// Unknown particle id or API shape mismatch: skip the effect silently.
}
}
/**
* Spawns a particle system with an explicit lifetime ({@code durationSeconds}) so it is
* guaranteed to disappear — used for looping/aura effects (e.g. the whirlwind) that would
* otherwise stay on screen forever.
*/
public static void vfxTimed(
@Nonnull Store<EntityStore> store,
@Nonnull String particleSystemId,
@Nonnull Vector3d position,
float scale,
float durationSeconds) {
try {
ParticleUtil.spawnParticleEffect(
particleSystemId, new Vector3d(position), 0f, 0f, 0f, scale, durationSeconds, store);
} catch (Throwable ignored) {
// Unknown particle id or API shape mismatch: skip the effect silently.
}
}
/**
* Applies a client-synced knockback/impulse to an entity (player or mob) via the engine's
* {@link KnockbackComponent}. This is the only reliable way to move a <em>player</em> from the
* server: directly writing {@code Velocity} is ignored because player movement is
* client-authoritative. Must be called on the world thread.
*/
public static void knockback(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> entity,
@Nonnull Vector3d velocity) {
try {
KnockbackComponent kb = store.ensureAndGetComponent(entity, KnockbackComponent.getComponentType());
kb.setVelocity(new Vector3d(velocity));
kb.setVelocityType(ChangeVelocityType.Set);
kb.setVelocityConfig(defaultVelocityConfig());
kb.setDuration(0f);
kb.setTimer(0f);
} catch (Throwable ignored) {
// Knockback API mismatch: skip silently (ability still deals damage / plays effects).
}
}
@Nonnull
private static VelocityConfig defaultVelocityConfig() {
// Mirrors the engine's default knockback decay preset (see KnockbackApplyCommand).
VelocityConfig config = new VelocityConfig();
config.setAirResistance(0.99f);
config.setAirResistanceMax(0.98f);
config.setGroundResistance(0.94f);
config.setGroundResistanceMax(0.3f);
config.setThreshold(3.0f);
config.setStyle(VelocityThresholdStyle.Linear);
return config;
}
/** Plays a 3D sound event (broadcast to nearby players) at a position. */
public static void sound(
@Nonnull Store<EntityStore> store,
@Nonnull String soundEventId,
@Nonnull Vector3d position) {
sound(store, soundEventId, position, 1.0f, 1.0f);
}
public static void sound(
@Nonnull Store<EntityStore> store,
@Nonnull String soundEventId,
@Nonnull Vector3d position,
float volume,
float pitch) {
try {
int index = SoundEvent.getAssetMap().getIndexOrDefault(soundEventId, -1);
if (index < 0) {
return;
}
SoundUtil.playSoundEvent3d(
index, SoundCategory.SFX, position.x, position.y, position.z, volume, pitch, store);
} catch (Throwable ignored) {
// Unknown sound id or API shape mismatch: skip the sound silently.
}
}
/**
* Plays a weapon animation move on the entity (e.g. {@code WhirlwindChargedSpin}). The move must
* belong to the supplied weapon animation set ({@code Battleaxe} / {@code Longsword}); a null set
* skips the animation.
*/
public static void animation(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> entity,
@Nullable String weaponAnimationSet,
@Nonnull String animationMove) {
if (weaponAnimationSet == null) {
return;
}
try {
AnimationUtils.playAnimation(
entity, AnimationSlot.Action, weaponAnimationSet, animationMove, store);
} catch (Throwable ignored) {
// Animation set/move not loaded for this entity: skip silently.
}
}
}
@@ -0,0 +1,58 @@
package com.disklexar.mmorpg.combat;
import javax.annotation.Nonnull;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks lightweight combat state per player:
* <ul>
* <li>last time the player took damage (drives the "Résilient" power and out-of-combat detection),</li>
* <li>temporary resistance debuffs applied by abilities (e.g. the Knight war cry).</li>
* </ul>
*/
public final class CombatStateService {
/** A player is considered "in combat" for this long after taking or dealing damage. */
public static final long IN_COMBAT_WINDOW_MILLIS = 6_000L;
private final Map<UUID, Long> lastDamageTakenAt = new ConcurrentHashMap<>();
private final Map<UUID, Long> lastCombatAt = new ConcurrentHashMap<>();
private final Map<UUID, Long> resistanceDebuffUntil = new ConcurrentHashMap<>();
public void markDamageTaken(@Nonnull UUID uuid) {
long now = System.currentTimeMillis();
lastDamageTakenAt.put(uuid, now);
lastCombatAt.put(uuid, now);
}
public void markCombat(@Nonnull UUID uuid) {
lastCombatAt.put(uuid, System.currentTimeMillis());
}
public boolean wasRecentlyHit(@Nonnull UUID uuid, long windowMillis) {
Long at = lastDamageTakenAt.get(uuid);
return at != null && System.currentTimeMillis() - at <= windowMillis;
}
public boolean isInCombat(@Nonnull UUID uuid) {
Long at = lastCombatAt.get(uuid);
return at != null && System.currentTimeMillis() - at <= IN_COMBAT_WINDOW_MILLIS;
}
public void applyResistanceDebuff(@Nonnull UUID uuid, long durationMillis) {
resistanceDebuffUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean hasResistanceDebuff(@Nonnull UUID uuid) {
Long until = resistanceDebuffUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void clear(@Nonnull UUID uuid) {
lastDamageTakenAt.remove(uuid);
lastCombatAt.remove(uuid);
resistanceDebuffUntil.remove(uuid);
}
}
@@ -0,0 +1,69 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.core.service.Service;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nonnull;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
/**
* A small dedicated scheduler for timed gameplay effects (ability ticks, buff reconciliation).
* <p>
* The scheduler runs on its own thread; callbacks that touch ECS state must hop onto the world
* thread via {@link com.hypixel.hytale.server.core.universe.world.World#execute(Runnable)}.
*/
public final class MmorpgScheduler implements Service {
private final HytaleLogger logger;
private ScheduledExecutorService executor;
public MmorpgScheduler(@Nonnull MmorpgPlugin plugin) {
this.logger = plugin.getLogger();
}
@Override
public void start() {
executor = Executors.newScheduledThreadPool(2, runnable -> {
Thread thread = new Thread(runnable, "mmorpg-scheduler");
thread.setDaemon(true);
return thread;
});
}
@Override
public void shutdown() {
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
public void runLater(@Nonnull Runnable task, long delayMillis) {
if (executor == null) {
return;
}
executor.schedule(wrap(task), Math.max(0L, delayMillis), TimeUnit.MILLISECONDS);
}
@Nonnull
public ScheduledFuture<?> runRepeating(@Nonnull Runnable task, long initialDelayMillis, long periodMillis) {
return executor.scheduleAtFixedRate(
wrap(task), Math.max(0L, initialDelayMillis), Math.max(1L, periodMillis), TimeUnit.MILLISECONDS);
}
@Nonnull
private Runnable wrap(@Nonnull Runnable task) {
return () -> {
try {
task.run();
} catch (Throwable t) {
logger.at(Level.WARNING).log("MMORPG scheduled task failed: %s", t.getMessage());
}
};
}
}
@@ -0,0 +1,92 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.core.service.Service;
import com.disklexar.mmorpg.player.OnlinePlayer;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.MovementSettings;
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.util.concurrent.ScheduledFuture;
/**
* Reconciles passive movement effects every couple of seconds.
* <p>
* The "Sprinter" power grants {@code +10%} base movement speed while the player is out of combat;
* the bonus is removed while in combat and restored afterwards.
*/
public final class PassiveEffectService implements Service {
private static final long RECONCILE_PERIOD = 2_000L;
private final MmorpgScheduler scheduler;
private final OnlinePlayerRegistry online;
private final PlayerSessionService sessions;
private final CombatStateService combatState;
private ScheduledFuture<?> task;
public PassiveEffectService(
@Nonnull MmorpgPlugin plugin,
@Nonnull MmorpgScheduler scheduler,
@Nonnull OnlinePlayerRegistry online,
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState) {
this.scheduler = scheduler;
this.online = online;
this.sessions = sessions;
this.combatState = combatState;
}
@Override
public void start() {
task = scheduler.runRepeating(this::reconcileAll, RECONCILE_PERIOD, RECONCILE_PERIOD);
}
@Override
public void shutdown() {
if (task != null) {
task.cancel(false);
task = null;
}
}
private void reconcileAll() {
for (OnlinePlayer player : online.all()) {
PlayerProfile profile = sessions.getSession(player.uuid());
if (profile == null) {
continue;
}
boolean sprinter = profile.getPowers().contains(PowerCatalog.SPRINTER.id());
boolean active = sprinter && !combatState.isInCombat(player.uuid());
player.world().execute(() -> applySpeed(player, active));
}
}
private void applySpeed(@Nonnull OnlinePlayer player, boolean buffed) {
Store<EntityStore> store = player.world().getEntityStore().getStore();
Ref<EntityStore> ref = player.entityRef();
MovementManager movement = store.getComponent(ref, MovementManager.getComponentType());
if (movement == null) {
return;
}
MovementSettings defaults = movement.getDefaultSettings();
MovementSettings current = movement.getSettings();
if (defaults == null || current == null) {
return;
}
float baseline = defaults.baseSpeed;
float target = buffed ? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS) : baseline;
if (Math.abs(current.baseSpeed - target) > 0.0001f) {
current.baseSpeed = target;
movement.update(player.playerRef().getPacketHandler());
}
}
}
@@ -0,0 +1,64 @@
package com.disklexar.mmorpg.combat;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Applies a damage-over-time poison to a target. Each application is independent (so the assassin's
* "Lame Empoisonnée" stacks without limit): calling {@link #applyPoison} again simply schedules
* another series of ticks on top of the existing ones.
*/
public final class PoisonService {
private static final String VFX_POISON = "Impact_Poison";
private final MmorpgScheduler scheduler;
public PoisonService(@Nonnull MmorpgScheduler scheduler) {
this.scheduler = scheduler;
}
/**
* Deals {@code damagePerTick} magic damage to {@code victim} once per {@code periodMillis},
* {@code ticks} times. The caster keeps the kill credit via the damage source.
*/
public void applyPoison(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> caster,
@Nonnull Ref<EntityStore> victim,
int ticks,
float damagePerTick,
long periodMillis) {
AtomicInteger remaining = new AtomicInteger(ticks);
for (int i = 1; i <= ticks; i++) {
scheduler.runLater(() -> world.execute(() -> {
if (remaining.decrementAndGet() < 0) {
return;
}
if (!victim.isValid()) {
return;
}
// No dedicated "magic" cause exists; ENVIRONMENT keeps the tick from re-triggering
// the on-hit (PHYSICAL) buff logic, so the poison cannot recursively re-poison.
Damage damage = new Damage(new Damage.EntitySource(caster), DamageCause.ENVIRONMENT, damagePerTick);
DamageSystems.executeDamage(victim, store, damage);
TransformComponent transform =
store.getComponent(victim, TransformComponent.getComponentType());
if (transform != null) {
CombatFx.vfx(store, VFX_POISON, new Vector3d(transform.getPosition()));
}
}), (long) i * periodMillis);
}
}
}
@@ -0,0 +1,32 @@
package com.disklexar.mmorpg.combat;
import javax.annotation.Nonnull;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks which entities are currently stunned (used so the Knight war cry can deal bonus damage
* to already-stunned targets). The actual movement lock is enforced by repeatedly zeroing the
* target's velocity for the stun duration.
*/
public final class StunService {
private final Map<UUID, Long> stunnedUntil = new ConcurrentHashMap<>();
public void stun(@Nonnull UUID entityUuid, long durationMillis) {
stunnedUntil.put(entityUuid, System.currentTimeMillis() + durationMillis);
}
public boolean isStunned(@Nonnull UUID entityUuid) {
Long until = stunnedUntil.get(entityUuid);
if (until == null) {
return false;
}
if (System.currentTimeMillis() >= until) {
stunnedUntil.remove(entityUuid);
return false;
}
return true;
}
}
@@ -0,0 +1,66 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.economy.EconomyService;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.job.JobCatalog;
import com.disklexar.mmorpg.rpg.job.JobService;
import com.hypixel.hytale.component.ArchetypeChunk;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.component.system.EntityEventSystem;
import com.hypixel.hytale.server.core.event.events.ecs.BreakBlockEvent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
/**
* Grants money to players with the "Mineur" job each time they break a block.
*/
public final class MinerBlockBreakSystem extends EntityEventSystem<EntityStore, BreakBlockEvent> {
private static final Query<EntityStore> QUERY = Query.any();
private final PlayerSessionService sessions;
private final JobService jobs;
private final EconomyService economy;
public MinerBlockBreakSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull JobService jobs,
@Nonnull EconomyService economy) {
super(BreakBlockEvent.class);
this.sessions = sessions;
this.jobs = jobs;
this.economy = economy;
}
@Override
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public void handle(
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull BreakBlockEvent event) {
Ref<EntityStore> breaker = chunk.getReferenceTo(index);
PlayerRef player = store.getComponent(breaker, PlayerRef.getComponentType());
if (player == null) {
return;
}
PlayerProfile profile = sessions.getSession(player.getUuid());
if (profile == null) {
return;
}
if (jobs.has(profile, JobCatalog.MINER.id())) {
economy.deposit(profile, JobCatalog.MINER_REWARD);
}
}
}
@@ -0,0 +1,143 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.combat.CombatBuffService;
import com.disklexar.mmorpg.combat.CombatStateService;
import com.disklexar.mmorpg.combat.PoisonService;
import com.disklexar.mmorpg.player.OnlinePlayer;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
import com.hypixel.hytale.component.ArchetypeChunk;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.SystemGroup;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.server.core.entity.UUIDComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.util.UUID;
/**
* Adjusts incoming damage according to MMORPG state and tracks combat for passive powers:
* <ul>
* <li>victims with an active resistance debuff (Knight war cry) take +20% damage,</li>
* <li>attackers with the "Résilient" power deal +10% damage for 5s after being hit.</li>
* </ul>
*/
public final class MmorpgDamageSystem extends DamageEventSystem {
private static final Query<EntityStore> QUERY = Query.any();
private static final double RESISTANCE_DEBUFF_FACTOR = 1.20;
// Archer "Tir rapide": flat outgoing-damage bonus (server proxy for attack speed).
private static final double RAPID_FIRE_FACTOR = 1.20;
// Archer "Tir puissant": +200% on the next basic attack.
private static final double POWER_SHOT_FACTOR = 3.0;
// Assassin "Lame Empoisonnée": stacking poison applied on each basic hit.
private static final int POISON_TICKS = 5;
private static final float POISON_DAMAGE_PER_TICK = 2.0f;
private static final long POISON_PERIOD_MILLIS = 1_000L;
private final PlayerSessionService sessions;
private final CombatStateService combatState;
private final CombatBuffService combatBuffs;
private final PoisonService poisonService;
private final OnlinePlayerRegistry onlinePlayers;
public MmorpgDamageSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs,
@Nonnull PoisonService poisonService,
@Nonnull OnlinePlayerRegistry onlinePlayers) {
this.sessions = sessions;
this.combatState = combatState;
this.combatBuffs = combatBuffs;
this.poisonService = poisonService;
this.onlinePlayers = onlinePlayers;
}
@Override
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public SystemGroup<EntityStore> getGroup() {
return DamageModule.get().getInspectDamageGroup();
}
@Override
public void handle(
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull Damage damage) {
Ref<EntityStore> victim = chunk.getReferenceTo(index);
double amount = damage.getAmount();
UUID victimUuid = uuid(store, victim);
if (victimUuid != null && combatState.hasResistanceDebuff(victimUuid)) {
amount *= RESISTANCE_DEBUFF_FACTOR;
}
if (damage.getSource() instanceof Damage.EntitySource source) {
Ref<EntityStore> attackerRef = source.getRef();
if (attackerRef != null) {
PlayerRef attacker = store.getComponent(attackerRef, PlayerRef.getComponentType());
if (attacker != null) {
UUID attackerUuid = attacker.getUuid();
combatState.markCombat(attackerUuid);
PlayerProfile profile = sessions.getSession(attackerUuid);
if (profile != null
&& profile.getPowers().contains(PowerCatalog.RESILIENT.id())
&& combatState.wasRecentlyHit(attackerUuid, PowerCatalog.RESILIENT_WINDOW_MILLIS)) {
amount *= (1.0 + PowerCatalog.RESILIENT_DAMAGE_BONUS);
}
// Offensive class buffs only apply to direct physical hits, never to the
// damage-over-time / spell ticks our own abilities deal (those are MAGIC), so a
// single sword swing doesn't waste the archer's charged shot, etc.
if (damage.getCause() == DamageCause.PHYSICAL) {
if (combatBuffs.hasRapidFire(attackerUuid)) {
amount *= RAPID_FIRE_FACTOR;
}
if (combatBuffs.consumePowerShot(attackerUuid)) {
amount *= POWER_SHOT_FACTOR;
}
if (combatBuffs.hasPoisonBlade(attackerUuid)) {
OnlinePlayer online = onlinePlayers.get(attackerUuid);
if (online != null) {
poisonService.applyPoison(online.world(), store, attackerRef, victim,
POISON_TICKS, POISON_DAMAGE_PER_TICK, POISON_PERIOD_MILLIS);
}
}
}
}
}
}
if (Math.abs(amount - damage.getAmount()) > 0.0001) {
damage.setAmount((float) amount);
}
PlayerRef victimPlayer = store.getComponent(victim, PlayerRef.getComponentType());
if (victimPlayer != null) {
combatState.markDamageTaken(victimPlayer.getUuid());
}
}
private UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
}
@@ -0,0 +1,105 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.economy.EconomyService;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.progression.ProgressionService;
import com.disklexar.mmorpg.rpg.group.GroupService;
import com.disklexar.mmorpg.rpg.job.JobCatalog;
import com.disklexar.mmorpg.rpg.job.JobService;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.util.UUID;
/**
* Rewards a player for killing a mob: kill XP (scaled by race), shared XP for group mates, and
* money when the killer holds the "Tueur de monstre" job.
*/
public final class MmorpgDeathSystem extends DeathSystems.OnDeathSystem {
private static final Query<EntityStore> QUERY = Query.any();
private final PlayerSessionService sessions;
private final ProgressionService progression;
private final GroupService groups;
private final JobService jobs;
private final EconomyService economy;
private final int killExperience;
public MmorpgDeathSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull ProgressionService progression,
@Nonnull GroupService groups,
@Nonnull JobService jobs,
@Nonnull EconomyService economy,
int killExperience) {
this.sessions = sessions;
this.progression = progression;
this.groups = groups;
this.jobs = jobs;
this.economy = economy;
this.killExperience = Math.max(0, killExperience);
}
@Override
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public void onComponentAdded(
@Nonnull Ref<EntityStore> deadRef,
@Nonnull DeathComponent death,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
// Only mobs award rewards; ignore player deaths.
if (store.getComponent(deadRef, PlayerRef.getComponentType()) != null) {
return;
}
Damage info = death.getDeathInfo();
if (info == null || !(info.getSource() instanceof Damage.EntitySource source)) {
return;
}
Ref<EntityStore> killerRef = source.getRef();
if (killerRef == null) {
return;
}
PlayerRef killer = store.getComponent(killerRef, PlayerRef.getComponentType());
if (killer == null) {
return;
}
UUID killerUuid = killer.getUuid();
PlayerProfile profile = sessions.getSession(killerUuid);
if (profile == null) {
return;
}
long gained = progression.grantExperience(profile, killExperience, true);
int shared = groups.shareKillExperience(killerUuid, gained);
long reward = 0L;
if (jobs.has(profile, JobCatalog.MONSTER_SLAYER.id())) {
reward = JobCatalog.MONSTER_SLAYER_REWARD;
economy.deposit(profile, reward);
}
StringBuilder message = new StringBuilder("Monstre vaincu — +" + gained + " XP");
if (reward > 0) {
message.append(" | +").append(reward).append(" money");
}
if (shared > 0) {
message.append(" | XP partagée avec ").append(shared).append(" allié(s)");
}
killer.sendMessage(Message.raw(message.toString()));
}
}