Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,14 @@ private void loadSpawnFilters() {

private void loadDetectionSettings() {
maxDetectionDistance = plugin.getConfig().getDouble("detection.max-detection-distance", 2500.0);
pinpointRange = plugin.getConfig().getDouble("detection.pinpoint-range", 12.0);
closeRangeThreshold = plugin.getConfig().getDouble("detection.close-range-threshold", 200.0);
pinpointRange = plugin.getConfig().getDouble("detection.pinpoint-range", 20.0);
closeRangeThreshold = plugin.getConfig().getDouble("detection.close-range-threshold", 60.0);
threeRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.three-rings", 100.0);
twoRingsDistance = plugin.getConfig().getDouble("detection.ring-thresholds.two-rings", 300.0);

if (pinpointRange <= 0.0) {
plugin.getLogger().warning("detection.pinpoint-range must be greater than 0 - falling back to 12.");
pinpointRange = 12.0;
plugin.getLogger().warning("detection.pinpoint-range must be greater than 0 - falling back to 20.");
pinpointRange = 20.0;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.bukkit.SoundCategory;
import org.bukkit.entity.Player;
import net.tfminecraft.geigercounters.config.GeigerConfiguration;
import net.tfminecraft.geigercounters.utils.Closeness;

import java.util.HashMap;
import java.util.Iterator;
Expand Down Expand Up @@ -45,8 +46,8 @@ public GeigerClickPlayer(GeigerConfiguration config) {
}

// ====================================
// Record how fast this player should be clicking
// Called by the periodic player check, not every tick
// Record how fast this player should be clicking for a cache <distance>
// blocks away in 3D. Called by the periodic player check, not every tick
// ====================================
public void updateRate(Player player, double distance) {
setRate(player, calculateClicksPerTick(distance));
Expand Down Expand Up @@ -127,7 +128,8 @@ public void clear() {
// ====================================
// Map distance to clicks per tick.
//
// Full rate at the source, falling to the minimum at max detection range.
// Full rate at pinpoint range, falling to the minimum at max detection
// range on a log scale, so the rate still climbs in the last stretch.
// The curve exponent biases the fast clicking toward close range, so the
// "getting hot" feeling lands where the player is actually hunting.
// ====================================
Expand All @@ -137,8 +139,8 @@ private double calculateClicksPerTick(double distance) {
return 0.0;
}

double progress = Math.min(1.0, Math.max(0.0, distance / maxDistance));
double closeness = Math.pow(1.0 - progress, config.getSoundCurve());
double closeness = Math.pow(
Closeness.logarithmic(distance, config.getPinpointRange(), maxDistance), config.getSoundCurve());

double minRate = config.getSoundMinRate();
double maxRate = config.getSoundMaxRate();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.bukkit.Location;
import org.bukkit.entity.Player;
import net.tfminecraft.geigercounters.config.GeigerConfiguration;
import net.tfminecraft.geigercounters.utils.Closeness;

import java.util.HashMap;
import java.util.Iterator;
Expand All @@ -18,8 +19,10 @@
// Boss bar shown while a player holds a detector.
//
// It fills as the player closes in and peaks when they face their source, so
// sweeping round on the spot shows which way to walk. Within pinpoint range
// it retitles and shows closeness to the cache alone, to find where to dig.
// sweeping round on the spot shows which way to walk. It fills on a log
// scale, reaching full at pinpoint range, so it keeps moving on the way in.
// Within pinpoint range it retitles and shows closeness to the cache alone,
// to find where to dig.
// MMOCore owns the action bar, which is why this sits at the top of the screen.
// ====================================
public class SignalBar {
Expand All @@ -43,7 +46,7 @@ public SignalBar(GeigerConfiguration config) {
}

// ====================================
// Show or refresh the player's bar for a source <distance> blocks away
// Show or refresh the player's bar for a cache <distance> blocks away in 3D
// ====================================
public void update(Player player, Location source, double distance) {
show(player, signal(player.getLocation(), source, distance), TITLE);
Expand Down Expand Up @@ -121,8 +124,7 @@ private static void hideFrom(UUID playerId, BossBar bar) {
// Facing away still shows (1 - facing-weight) of the closeness.
// ====================================
double signal(Location from, Location source, double distance) {
double range = config.getBarRange();
double closeness = 1.0 - Math.min(distance, range) / range;
double closeness = Closeness.logarithmic(distance, config.getPinpointRange(), config.getBarRange());
double weight = config.getBarFacingWeight();
return closeness * ((1.0 - weight) + weight * facing(from, source));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,15 @@ private boolean handlePlayerWithGeiger(Player player, UUID hunter, Set<UUID> rev

particleRenderer.showParticleEffect(player, distance);

// Depth counts once the hunter is close enough to start digging
// The readings count depth, so standing high above the cache never
// reads as full; within pinpoint range they switch to the close reading
double reach = player.getLocation().distance(source);
if (reach <= configuration.getPinpointRange()) {
clickPlayer.updatePinpoint(player, reach);
signalBar.pinpoint(player, reach);
} else {
clickPlayer.updateRate(player, distance);
signalBar.update(player, source, distance);
clickPlayer.updateRate(player, reach);
signalBar.update(player, source, reach);
}

if (reach <= BuriedChest.REVEAL_DISTANCE) {
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/net/tfminecraft/geigercounters/utils/Closeness.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package net.tfminecraft.geigercounters.utils;

// ====================================
// How close a reading is, from 0 at <far> to 1 at <near> and closer.
//
// The scale is logarithmic: every halving of the distance adds the same
// amount. On a straight line a 2,500-block range leaves the last few hundred
// blocks reading as all but full, so nothing changes where players need it.
// ====================================
public final class Closeness {

private Closeness() {
}

public static double logarithmic(double distance, double near, double far) {
if (distance <= near) {
return 1.0;
}
if (distance >= far) {
return 0.0;
}
return 1.0 - Math.log(distance / near) / Math.log(far / near);
}
}
13 changes: 8 additions & 5 deletions src/main/java/net/tfminecraft/geigercounters/utils/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@

public class Utils {

private static final Pattern HEX_PATTERN = Pattern.compile("&?(#[a-fA-F0-9]{6})");

// Durations like "12h", "90m", "1d", "30s" - or a bare number, read as seconds
private static final Pattern DURATION_PATTERN = Pattern.compile("(?i)^[ \t]*([0-9]+(?:[.][0-9]+)?)[ \t]*([smhd]?)[ \t]*$");

// Keep the existing legacy text representation, formatting, and exact-string comparisons.
// Hex colours may be written #RRGGBB or &#RRGGBB; the & goes with the colour.
@SuppressWarnings("deprecation")
public static String colorize(String msg) {
Matcher match = Pattern.compile("#[a-fA-F0-9]{6}").matcher(msg);
Matcher match = HEX_PATTERN.matcher(msg);
StringBuilder colored = new StringBuilder();
while (match.find()) {
String color = msg.substring(match.start(), match.end());
msg = msg.replace(color, String.valueOf(ChatColor.of(color)));
match = Pattern.compile("#[a-fA-F0-9]{6}").matcher(msg);
match.appendReplacement(colored, Matcher.quoteReplacement(String.valueOf(ChatColor.of(match.group(1)))));
}
return ChatColor.translateAlternateColorCodes('&', msg);
match.appendTail(colored);
return ChatColor.translateAlternateColorCodes('&', colored.toString());
}

// ====================================
Expand Down
28 changes: 18 additions & 10 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,15 @@ detection:

# Within this many blocks of the cache (measured in 3D, depth included)
# the detector switches to a close reading: the bar and clicks start over
# from low and peak on the cache itself, to show where to dig
pinpoint-range: 12.0

# Distance threshold for close/far range colors (blocks)
close-range-threshold: 200.0
# from low and peak on the cache itself, to show where to dig.
# Caches lie up to burial.max-depth under the ground, so keep this well
# above that or the close reading barely reaches the surface on slopes.
pinpoint-range: 20.0

# Distance threshold for close/far range colors (blocks). The particles
# turn white over this last stretch, so a large value makes them look
# white long before the close reading starts.
close-range-threshold: 60.0

# Ring count thresholds - number of particle rings based on distance
ring-thresholds:
Expand All @@ -100,8 +104,10 @@ detection:
# ====================================
# Signal bar
# A boss bar shown while holding a detector. It fills as the player gets
# closer and peaks when they face their source. Within pinpoint-range it
# shows closeness to the cache alone.
# closer and peaks when they face their source. Distance is measured in 3D
# on a log scale: each halving of the distance fills the same amount, and the
# bar nears full just outside pinpoint-range. There it switches to the close
# reading, which starts empty and fills up on the cache itself.
# ====================================
bar:
enabled: true
Expand Down Expand Up @@ -135,13 +141,15 @@ sound:
pitch: 1.7
pitch-variance: 0.15

# Clicks per second at max-detection-distance and at the source itself.
# 20 is the ceiling - one click per tick
# Clicks per second at max-detection-distance and just outside
# pinpoint-range. Like the bar, the rate climbs on a log scale of the 3D
# distance; at pinpoint-range it drops back to min-rate and climbs again
# to max-rate on the cache. 20 is the ceiling - one click per tick
min-rate: 0.5
max-rate: 18.0

# Higher values keep the clicking slow until the player is close,
# then ramp hard. 1.0 is a straight line
# then ramp hard. 1.0 follows the log scale as it is
curve: 2.0


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ void colorsAndCollectionEventPreservePublicContract() {
assertEquals("§aGreen", Utils.colorize("&aGreen"));
assertEquals(
"§x§f§f§0§0§a§aPink §x§f§f§0§0§a§aAgain", Utils.colorize("#ff00aaPink #ff00aaAgain"));
// The documented &#RRGGBB form leaves no stray &
assertEquals(
"§x§a§a§0§0§f§fArcane §x§a§a§0§0§f§fSignal §7- dig",
Utils.colorize("#aa00ffArcane &#aa00ffSignal &7- dig"));
Player player = mock(Player.class);
Location location = new Location(mock(World.class), 1, 2, 3);
var event = new GeigerSourceCollectEvent(player, location);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ void defaultsProvideUsableDetectionSoundLimitsColorsAndSpawnFilters() {
assertSame(world, configuration.getWorld());
assertEquals(GeigerConfiguration.HuntMode.PLAYER, configuration.getHuntMode());
assertEquals(2500, configuration.getMaxDetectionDistance());
assertEquals(12, configuration.getPinpointRange());
assertEquals(20, configuration.getPinpointRange());
assertEquals(300, configuration.getMinSourceDistance());
assertEquals(600, configuration.getMaxSourceDistance());
assertEquals(3, configuration.getMinBurialDepth());
Expand All @@ -62,7 +62,7 @@ void defaultsProvideUsableDetectionSoundLimitsColorsAndSpawnFilters() {
assertTrue(configuration.isBarEnabled());
assertEquals(750, configuration.getBarRange());
assertEquals(0.6, configuration.getBarFacingWeight());
assertEquals(200, configuration.getCloseRangeThreshold());
assertEquals(60, configuration.getCloseRangeThreshold());
assertEquals(100, configuration.getThreeRingsDistance());
assertEquals(300, configuration.getTwoRingsDistance());
assertTrue(configuration.isSoundEnabled());
Expand Down Expand Up @@ -148,7 +148,7 @@ void sourceDistancesAndBarSettingsAreKeptConsistent() {
load();
assertEquals(1, configuration.getMinBurialDepth());
assertEquals(1, configuration.getMaxBurialDepth());
assertEquals(12, configuration.getPinpointRange());
assertEquals(20, configuration.getPinpointRange());
assertEquals(0, configuration.getMinSourceDistance());
assertEquals(0, configuration.getMaxSourceDistance());
assertEquals(0, configuration.getRerollDistance());
Expand All @@ -162,11 +162,11 @@ void sourceDistancesAndBarSettingsAreKeptConsistent() {
yaml.set("bar.facing-weight", -1);
yaml.set("source.burial.min-depth", 2);
yaml.set("source.burial.max-depth", 8);
yaml.set("detection.pinpoint-range", 20);
yaml.set("detection.pinpoint-range", 30);
load();
assertEquals(2, configuration.getMinBurialDepth());
assertEquals(8, configuration.getMaxBurialDepth());
assertEquals(20, configuration.getPinpointRange());
assertEquals(30, configuration.getPinpointRange());
assertEquals(100, configuration.getMinSourceDistance());
assertEquals(300, configuration.getMaxSourceDistance());
assertEquals(600, configuration.getRerollDistance());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ void setUp() {
when(player.isOnline()).thenReturn(true);
when(player.getLocation()).thenAnswer(invocation -> new Location(world, 10, 64, 20));
when(config.getMaxDetectionDistance()).thenReturn(1000.0);
when(config.getPinpointRange()).thenReturn(10.0);
when(config.getCloseRangeThreshold()).thenReturn(50.0);
when(config.getThreeRingsDistance()).thenReturn(100.0);
when(config.getTwoRingsDistance()).thenReturn(300.0);
Expand Down Expand Up @@ -127,6 +128,25 @@ void soundRateFallsWithDistanceAndStopsOutsideDetectionRange() throws Exception
}
}

@Test
void soundRateClimbsOnALogScaleAndPeaksAtPinpointRange() throws Exception {
// Clicks per tick: 1 at pinpoint range; at 100 blocks closeness 0.5,
// squared by the curve, gives (1 + 19 * 0.25) / 20 = 0.2875
double[][] cases = {{10, 0.999, 1}, {100, 0.28, 1}, {100, 0.29, 0}};
for (double[] reading : cases) {
GeigerClickPlayer clicker = clicker(reading[1]);
clearInvocations(player);
try (var bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player);
clicker.updateRate(player, reading[0]);
clicker.tick();
}
verify(player, times((int) reading[2]))
.playSound(
any(Location.class), anyString(), any(SoundCategory.class), anyFloat(), anyFloat());
}
}

@Test
void pinpointRateRestartsAtMinRateOnTheEdgeAndPeaksOnTheCache() throws Exception {
when(config.getPinpointRange()).thenReturn(10.0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,18 @@ BossBar shown() {
return captor.getValue();
}

// Closeness at 50 blocks on the 10..100 log scale
static final double AT_50 = 1.0 - Math.log10(5);

@Test
void barIsShownOnceThenUpdatedInPlace() {
bar.update(player, new Location(world, 0, 64, 50), 50);
BossBar shown = shown();
assertEquals(0.5f, shown.progress(), 1e-6);
assertEquals(AT_50, shown.progress(), 1e-6);
assertEquals(BossBar.Color.PURPLE, shown.color());
assertEquals(BossBar.Overlay.NOTCHED_10, shown.overlay());
bar.update(player, new Location(world, 0, 64, -50), 50);
assertEquals(0.25f, shown.progress(), 1e-6);
assertEquals(AT_50 / 2, shown.progress(), 1e-6);
verify(player, times(1)).showBossBar(any(BossBar.class));
}

Expand All @@ -71,7 +74,7 @@ void pinpointRetitlesTheBarAndShowsClosenessAlone() {
bar.pinpoint(player, 30);
assertEquals(0f, shown.progress(), 1e-6);
bar.update(player, new Location(world, 0, 64, 50), 50);
assertEquals(0.5f, shown.progress(), 1e-6);
assertEquals(AT_50, shown.progress(), 1e-6);
assertEquals("Arcane signal", PlainTextComponentSerializer.plainText().serialize(shown.name()));
verify(player, times(1)).showBossBar(any(BossBar.class));
}
Expand Down Expand Up @@ -127,9 +130,15 @@ void retainHidesBarsOfPlayersNoLongerHuntingAndForgetsOfflineOnes() {
void signalFallsWithDistanceAndPeaksWhenFacingTheSource() {
Location from = player.getLocation();
assertEquals(1.0, bar.signal(from, new Location(world, 0, 64, 1), 0), 1e-9);
// Full from pinpoint range in, empty from bar range out
assertEquals(1.0, bar.signal(from, new Location(world, 0, 64, 10), 10), 1e-9);
assertEquals(0.0, bar.signal(from, new Location(world, 0, 64, 150), 150), 1e-9);
when(config.getBarFacingWeight()).thenReturn(0.0);
assertEquals(0.5, bar.signal(from, new Location(world, 0, 64, -50), 50), 1e-9);
assertEquals(AT_50, bar.signal(from, new Location(world, 0, 64, -50), 50), 1e-9);
// Every halving of the distance fills the same amount
Location behind = new Location(world, 0, 64, -1);
double at20 = bar.signal(from, behind, 20), at40 = bar.signal(from, behind, 40);
assertEquals(at20 - at40, at40 - bar.signal(from, behind, 80), 1e-9);
}

@Test
Expand Down
Loading
Loading