diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java index 1219422..973c2da 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrb.java @@ -2,6 +2,8 @@ import org.bukkit.Location; +import net.tfminecraft.magic.util.OrbTrail; + /** * One virtual orb circling a crafting station. Particle only, no entity, so it is never * hit by anything but the plugin's own hitscan. @@ -10,6 +12,7 @@ public final class GearOrb { private final Location anchor; private Location location; + private final OrbTrail trail = new OrbTrail(); private final boolean good; private double angle; private final double radius; @@ -54,6 +57,15 @@ public Location getLocation() { public void setLocation(Location location) { this.location = location.clone(); + trail.push(this.location); + } + + /** + * Where the orb was {@code ticksAgo} ticks back, for ping compensated hits, or null + * if it had not spawned yet. + */ + public Location getLocation(int ticksAgo) { + return trail.ticksAgo(ticksAgo); } public boolean isGood() { diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java index 3dce276..e5ae289 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java @@ -32,6 +32,7 @@ import net.tfminecraft.magic.gear.WeaponLore; import net.tfminecraft.magic.gear.WeaponRequirement; import net.tfminecraft.magic.gear.WeaponRift; +import net.tfminecraft.magic.util.OrbTrail; /** * Drives the orb runs. One session per station, so two players cannot attune the same @@ -251,24 +252,29 @@ private static GearOrb hitscan(Player player, GearOrbSession session) { double range = OrbCache.clickRange; double radiusSq = OrbCache.hitRadius * OrbCache.hitRadius; + // Judge the click against where the orb was when this player saw it. + int[] rewind = OrbTrail.rewindTicks(player, OrbCache.maxRewindTicks); + GearOrb best = null; double bestT = range + 1; for (GearOrb orb : session.livingOrbs()) { - Location loc = orb.getLocation(); - if (loc.getWorld() == null || !loc.getWorld().equals(world)) { - continue; - } - Vector oc = loc.toVector().subtract(origin); - double t = oc.dot(dir); - if (t < 0 || t > range) { - continue; - } - if (oc.lengthSquared() - t * t > radiusSq) { - continue; - } - if (t < bestT) { - bestT = t; - best = orb; + for (int ticksAgo : rewind) { + Location loc = orb.getLocation(ticksAgo); + if (loc == null || loc.getWorld() == null || !loc.getWorld().equals(world)) { + continue; + } + Vector oc = loc.toVector().subtract(origin); + double t = oc.dot(dir); + if (t < 0 || t > range) { + continue; + } + if (oc.lengthSquared() - t * t > radiusSq) { + continue; + } + if (t < bestT) { + bestT = t; + best = orb; + } } } if (best == null) { diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java b/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java index 7211f73..4650a0f 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java @@ -14,6 +14,8 @@ public final class OrbCache { public static double hitRadius = 0.5; public static double clickRange = 12.0; + /** Most ticks a click is rewound for the player's ping (10 = 500 ms). */ + public static int maxRewindTicks = 10; public static double orbitRadius = 2.4; public static double orbitHeight = 1.1; diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index 1fa788a..4209b70 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -248,6 +248,7 @@ private static void loadMeditation(ConfigurationSection section) { MeditationCache.startOrbRange = section.getDouble("start_orb_range", MeditationCache.startOrbRange); MeditationCache.orbHitRadius = section.getDouble("orb_hit_radius", MeditationCache.orbHitRadius); MeditationCache.orbClickRange = section.getDouble("orb_click_range", MeditationCache.orbClickRange); + MeditationCache.maxRewindTicks = Math.max(0, section.getInt("max_rewind_ticks", MeditationCache.maxRewindTicks)); MeditationCache.mentalCostPerHit = section.getInt("mental_cost_per_hit", MeditationCache.mentalCostPerHit); MeditationCache.surgeLockSeconds = section.getInt("surge_lock_seconds", MeditationCache.surgeLockSeconds); MeditationCache.maxLiveOrbs = section.getInt("max_live_orbs", MeditationCache.maxLiveOrbs); diff --git a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java index 247bb7c..4d77e25 100644 --- a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java @@ -165,6 +165,7 @@ private static boolean loadOrbs(File file) { } OrbCache.hitRadius = Math.max(0.1, config.getDouble("hit_radius", OrbCache.hitRadius)); OrbCache.clickRange = Math.max(1.0, config.getDouble("click_range", OrbCache.clickRange)); + OrbCache.maxRewindTicks = Math.max(0, config.getInt("max_rewind_ticks", OrbCache.maxRewindTicks)); OrbCache.orbitRadius = Math.max(0.5, config.getDouble("orbit_radius", OrbCache.orbitRadius)); OrbCache.orbitHeight = config.getDouble("orbit_height", OrbCache.orbitHeight); OrbCache.orbitBob = Math.max(0.0, config.getDouble("orbit_bob", OrbCache.orbitBob)); diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java index f571281..b8c7789 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationCache.java @@ -9,6 +9,8 @@ public final class MeditationCache { public static double startOrbRange = 2.5; public static double orbHitRadius = 0.5; public static double orbClickRange = 12.0; + /** Most ticks a click is rewound for the player's ping (10 = 500 ms). */ + public static int maxRewindTicks = 10; public static int mentalCostPerHit = 1; public static int surgeLockSeconds = 10; public static int maxLiveOrbs = 8; diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java index c59b1cf..7f15878 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationOrb.java @@ -4,9 +4,12 @@ import org.bukkit.Location; +import net.tfminecraft.magic.util.OrbTrail; + public final class MeditationOrb { private Location location; + private final OrbTrail trail = new OrbTrail(); private final Location spawnLocation; private Location returnFrom; private final UUID sourceId; @@ -64,6 +67,19 @@ public Location getLocation() { public void setLocation(Location location) { this.location = location.clone(); + trail.push(this.location); + } + + /** + * Where the orb was {@code ticksAgo} ticks back, for ping compensated hits, or null + * if it had not spawned yet. The starter follows the player's aim, so it is always + * judged where it is now. + */ + public Location getLocation(int ticksAgo) { + if (starter) { + return location; + } + return trail.ticksAgo(ticksAgo); } public Location getSpawnLocation() { diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java index 0a54d31..57896b1 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java @@ -29,6 +29,7 @@ import net.tfminecraft.magic.profile.MagicProfileService; import net.tfminecraft.magic.session.ResonanceSession; import net.tfminecraft.magic.session.ResonanceSessionManager; +import net.tfminecraft.magic.util.OrbTrail; import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.rpcharacters.focus.FocusService; @@ -250,25 +251,30 @@ private MeditationOrb hitscan(Player player, MeditationSession session) { double radiusSq = radius * radius; Vector origin = eye.toVector(); + // Judge the click against where the orb was when this player saw it. + int[] rewind = OrbTrail.rewindTicks(player, MeditationCache.maxRewindTicks); + MeditationOrb best = null; double bestT = range + 1; for (MeditationOrb orb : session.livingOrbs()) { - Location loc = orb.getLocation(); - if (loc.getWorld() == null || !loc.getWorld().equals(world)) { - continue; - } - Vector oc = loc.toVector().subtract(origin); - double t = oc.dot(dir); - if (t < 0 || t > range) { - continue; - } - double distSq = oc.lengthSquared() - t * t; - if (distSq > radiusSq) { - continue; - } - if (t < bestT) { - bestT = t; - best = orb; + for (int ticksAgo : rewind) { + Location loc = orb.getLocation(ticksAgo); + if (loc == null || loc.getWorld() == null || !loc.getWorld().equals(world)) { + continue; + } + Vector oc = loc.toVector().subtract(origin); + double t = oc.dot(dir); + if (t < 0 || t > range) { + continue; + } + double distSq = oc.lengthSquared() - t * t; + if (distSq > radiusSq) { + continue; + } + if (t < bestT) { + bestT = t; + best = orb; + } } } if (best == null) { diff --git a/src/main/java/net/tfminecraft/magic/util/OrbTrail.java b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java new file mode 100644 index 0000000..031dc56 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/util/OrbTrail.java @@ -0,0 +1,66 @@ +package net.tfminecraft.magic.util; + +import org.bukkit.Location; +import org.bukkit.entity.Player; + +/** + * Recent positions of a moving orb, one per tick, so a click can be judged against where + * the player actually saw it. + * + *
A player sees an orb about half their ping late and their click reaches us another + * half ping later, so by the time the swing lands the orb has moved on by roughly one + * round trip. Rewinding by the player's own ping keeps the game the same for everyone + * instead of widening the hit radius for all. + */ +public final class OrbTrail { + + /** Ticks kept per orb. Also the hard ceiling on any configured rewind. */ + public static final int CAPACITY = 20; + + private final Location[] positions = new Location[CAPACITY]; + private int head = -1; + private int size; + + /** Records this tick's position. Call once per tick, after the orb has moved. */ + public void push(Location location) { + head = (head + 1) % CAPACITY; + positions[head] = location.clone(); + if (size < CAPACITY) { + size++; + } + } + + /** + * Position {@code ticksAgo} ticks back, or null when the orb did not exist yet, so a + * late click is never matched to a frame the player could not have seen. + */ + public Location ticksAgo(int ticksAgo) { + int back = Math.max(0, ticksAgo); + if (back >= size) { + return null; + } + return positions[Math.floorMod(head - back, CAPACITY)]; + } + + /** + * Ticks to rewind for this player's ping, as the two whole ticks either side of it so + * a ping between ticks is judged against both frames the player could have seen. A + * ping of exactly whole ticks also gets the newer frame. Zero ping gets {@code [0, 0]} + * and low ping {@code [0, 1]}, so their game is unchanged. + */ + public static int[] rewindTicks(Player player, int maxTicks) { + int ping = player == null ? 0 : Math.max(0, player.getPing()); + return rewindTicks(ping, maxTicks); + } + + public static int[] rewindTicks(int pingMs, int maxTicks) { + int cap = Math.max(0, Math.min(maxTicks, CAPACITY - 1)); + double ticks = Math.min(cap, Math.max(0, pingMs) / 50.0); + int low = (int) Math.floor(ticks); + int high = Math.min(cap, (int) Math.ceil(ticks)); + if (low == high) { + low = Math.max(0, low - 1); + } + return new int[] {low, high}; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index aa8ada1..bb07f4c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -59,6 +59,9 @@ meditation: start_orb_range: 2.5 orb_hit_radius: 0.5 orb_click_range: 12 + # A click is judged against where the orb was when the player saw it, rewound by + # their ping up to this many ticks (10 = 500 ms). 0 turns it off. + max_rewind_ticks: 10 mental_cost_per_hit: 1 surge_lock_seconds: 10 max_live_orbs: 8 diff --git a/src/main/resources/gear/orbs.yml b/src/main/resources/gear/orbs.yml index 9ff8272..891bb27 100644 --- a/src/main/resources/gear/orbs.yml +++ b/src/main/resources/gear/orbs.yml @@ -11,6 +11,9 @@ # Hitscan. Radius is the cylinder around the aim ray an orb must fall inside. hit_radius: 0.5 click_range: 12 +# A click is judged against where the orb was when the player saw it, rewound by +# their ping up to this many ticks (10 = 500 ms). 0 turns it off. +max_rewind_ticks: 10 # Orbit around the station block centre. orbit_radius: 2.4