@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user