New spawn system: added block monster spawn probability weight calculator.

* Calculate spawn probability based on light sources, moon phase, and number of nearby entities.
* Added debug command and permissions to view spawn weight and causes for that spawn weight.

This commit will be followed up by further commits working on spawn qualities,
and the actual automatic spawn events to replace the built-in one,
and hopefully in the few days, I'll have a working spawning system.
master
James T. Martin 2020-11-19 03:20:31 -08:00
parent 33b8862cc2
commit 643e8e0f62
Signed by: james
GPG Key ID: 4B7F3DA9351E577C
9 changed files with 429 additions and 8 deletions

View File

@ -5,6 +5,7 @@ import java.io.IOException;
import java.sql.SQLException;
import java.util.logging.Level;
import me.jamestmartin.wasteland.commands.CommandDebugSpawnWeights;
import me.jamestmartin.wasteland.commands.CommandOfficial;
import me.jamestmartin.wasteland.commands.CommandRank;
import me.jamestmartin.wasteland.commands.CommandRankEligibleMobs;
@ -13,6 +14,7 @@ import me.jamestmartin.wasteland.commands.CommandSetKills;
import me.jamestmartin.wasteland.config.WastelandConfig;
import me.jamestmartin.wasteland.listeners.ChatListener;
import me.jamestmartin.wasteland.listeners.RankListener;
import me.jamestmartin.wasteland.spawns.WastelandSpawner;
import me.jamestmartin.wasteland.towny.TownyDependency;
import me.jamestmartin.wasteland.towny.TownyDisabled;
import me.jamestmartin.wasteland.towny.TownyPrefix;
@ -27,10 +29,15 @@ public class Wasteland extends JavaPlugin {
private WastelandConfig config;
private RankListener rankListener;
private TownyPrefix townyPrefix;
private WastelandSpawner spawner;
public static Wasteland getInstance() {
return instance;
}
public Database getDatabase() {
return database;
}
public WastelandConfig getSettings() {
return config;
@ -40,8 +47,8 @@ public class Wasteland extends JavaPlugin {
return townyPrefix;
}
public Database getDatabase() {
return database;
public WastelandSpawner getSpawner() {
return spawner;
}
public void updatePlayerRank(Player player) throws SQLException {
@ -83,6 +90,9 @@ public class Wasteland extends JavaPlugin {
this.getCommand("ranks").setExecutor(new CommandRanks());
this.getCommand("setkills").setExecutor(new CommandSetKills());
this.getCommand("official").setExecutor(new CommandOfficial());
// debug commands
this.getCommand("debugspawnweights").setExecutor(new CommandDebugSpawnWeights());
}
private void registerListeners() {
@ -100,8 +110,9 @@ public class Wasteland extends JavaPlugin {
initializeDatabase();
registerCommands();
registerListeners();
this.spawner = new WastelandSpawner(config.spawns());
}
@Override
public void onDisable() {
if (rankListener != null)

View File

@ -0,0 +1,59 @@
package me.jamestmartin.wasteland.commands;
import java.util.HashMap;
import java.util.Map.Entry;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import me.jamestmartin.wasteland.Wasteland;
import me.jamestmartin.wasteland.spawns.MonsterType;
import me.jamestmartin.wasteland.spawns.WastelandSpawner;
import me.jamestmartin.wasteland.world.MoonPhase;
public class CommandDebugSpawnWeights implements CommandExecutor {
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length > 0) {
sender.sendMessage("Too many arguments.");
return false;
}
if (!(sender instanceof Player)) {
sender.sendMessage("You must be a player to use this command.");
return false;
}
WastelandSpawner spawner = Wasteland.getInstance().getSpawner();
Player player = (Player) sender;
Block target = player.getTargetBlockExact(25).getRelative(BlockFace.UP);
MoonPhase phase = MoonPhase.fromWorld(target.getWorld());
int monsters = WastelandSpawner.countNearbyMonsters(target.getLocation());
int players = WastelandSpawner.countNearbyPlayers(target.getLocation());
int moonlight = MoonPhase.getMoonlight(target);
int sunlight = MoonPhase.getSunlight(target);
int blocklight = target.getLightFromBlocks();
HashMap<MonsterType, Float> weights = spawner.calculateSpawnProbabilities(target);
sender.sendMessage("Moon phase: " + phase);
sender.sendMessage("Moonlight: " + moonlight + ", Sunlight: " + sunlight + ", Blocklight: " + blocklight);
sender.sendMessage("Monsters: " + monsters + ", Players: " + players);
sender.sendMessage("Monster weights at " + target.getX() + ", " + target.getY() + ", " + target.getZ() + ":");
for (Entry<MonsterType, Float> entry : weights.entrySet()) {
MonsterType type = entry.getKey();
float weight = entry.getValue();
float lightProb = spawner.calculateLightLevelProbability(type, target);
float phaseMult = spawner.getMoonPhaseMultiplier(type, target);
float entitiesMult = WastelandSpawner.calculateNearbyEntitiesMultiplier(target);
sender.sendMessage(String.format("* %s: %.2f (light: %.2f, phase: %.2f, entities: %.2f)", type, weight, lightProb, phaseMult, entitiesMult));
}
return true;
}
}

View File

@ -1,6 +1,5 @@
package me.jamestmartin.wasteland.config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;

View File

@ -0,0 +1,76 @@
package me.jamestmartin.wasteland.config;
import java.util.HashMap;
import java.util.Map;
import org.bukkit.configuration.ConfigurationSection;
import me.jamestmartin.wasteland.world.MoonPhase;
public class MonsterSpawnConfig {
private final int maximumLightLevel;
private final float blocklightWeight;
private final float sunlightWeight;
private final float moonlightWeight;
private final int maximumYLevel;
private final int minimumYLevel;
private final Map<MoonPhase, Float> phaseMultipliers;
public MonsterSpawnConfig(final ConfigurationSection c) {
if (!c.isConfigurationSection("light")) {
this.maximumLightLevel = c.getInt("light", 9);
this.blocklightWeight = 1.0f;
this.sunlightWeight = 1.0f;
this.moonlightWeight = 0.0f;
} else {
final ConfigurationSection cLight = c.getConfigurationSection("light");
this.maximumLightLevel = cLight.getInt("maximum", 9);
this.blocklightWeight = (float) cLight.getDouble("weights.block", 1.0);
this.sunlightWeight = (float) cLight.getDouble("weights.sun", 1.0);
this.moonlightWeight = (float) cLight.getDouble("weights.moon", 0.0);
}
this.minimumYLevel = c.getInt("height.minimum", 0);
this.maximumYLevel = c.getInt("height.maximum", Integer.MAX_VALUE);
final ConfigurationSection cPhases = c.getConfigurationSection("phases");
this.phaseMultipliers = new HashMap<>();
if (cPhases != null) {
for (String phaseKey : cPhases.getKeys(false)) {
MoonPhase phase = MoonPhase.valueOf(phaseKey);
float multiplier = (float) cPhases.getDouble(phaseKey);
this.phaseMultipliers.put(phase, multiplier);
}
}
}
public int maximumLightLevel() {
return maximumLightLevel;
}
public float blocklightWeight() {
return blocklightWeight;
}
public float sunlightWeight() {
return sunlightWeight;
}
public float moonlightWeight() {
return moonlightWeight;
}
public int minimumYLevel() {
return minimumYLevel;
}
public int maximumYLevel() {
return maximumYLevel;
}
public Map<MoonPhase, Float> phaseMultipliers() {
return phaseMultipliers;
}
}

View File

@ -3,8 +3,10 @@ package me.jamestmartin.wasteland.config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@ -14,6 +16,7 @@ import org.bukkit.entity.EntityType;
import me.jamestmartin.wasteland.ranks.EnlistedRank;
import me.jamestmartin.wasteland.ranks.Rank;
import me.jamestmartin.wasteland.spawns.MonsterType;
public class WastelandConfig {
private final String databaseFile;
@ -28,7 +31,9 @@ public class WastelandConfig {
private final Set<EntityType> eligibleMobs;
private final String eligibleMobsName;
private final Map<MonsterType, MonsterSpawnConfig> spawns;
/** Orphaned method. */
public static Optional<ChatColor> readColor(ConfigurationSection c, String path) {
return (Optional<ChatColor>) Optional.ofNullable(c.getString(path)).map(ChatColor::valueOf);
@ -88,8 +93,18 @@ public class WastelandConfig {
for (String mobType : eligibleMobTypes) {
this.eligibleMobs.addAll(Arrays.asList(EntityTypes.lookupEntityType(mobType)));
}
this.eligibleMobsName = c.getString("eligibleMobsName");
ConfigurationSection mss = c.getConfigurationSection("spawns");
this.spawns = new HashMap<>();
if (mss != null) {
for (String typeName : mss.getKeys(false)) {
MonsterType type = MonsterType.valueOf(typeName);
MonsterSpawnConfig config = new MonsterSpawnConfig(mss.getConfigurationSection(typeName));
this.spawns.put(type, config);
}
}
}
public String databaseFile() { return this.databaseFile; }
@ -109,4 +124,6 @@ public class WastelandConfig {
public Set<EntityType> eligibleMobs() { return this.eligibleMobs; }
/** The term for the eligible mobs, e.g. "zombies" or "hostile mobs". */
public String eligibleMobsName() { return this.eligibleMobsName; }
public Map<MonsterType, MonsterSpawnConfig> spawns() { return this.spawns; }
}

View File

@ -0,0 +1,12 @@
package me.jamestmartin.wasteland.spawns;
public enum MonsterType {
/** A creeper or charged creeper, as appropriate. */
CREEPER,
/** A skeleton, wither skeleton, stray, etc., as appropriate. */
SKELETON,
/** A spider, cave spider, or spider jockey, as appropriate. */
SPIDER,
/** A zombie, husk, pig zombie, etc., as appropriate. */
ZOMBIE,
}

View File

@ -0,0 +1,180 @@
package me.jamestmartin.wasteland.spawns;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Monster;
import org.bukkit.entity.Player;
import me.jamestmartin.wasteland.config.MonsterSpawnConfig;
import me.jamestmartin.wasteland.world.MoonPhase;
public class WastelandSpawner {
private final Map<MonsterType, MonsterSpawnConfig> monsters;
public WastelandSpawner(Map<MonsterType, MonsterSpawnConfig> monsters) {
this.monsters = monsters;
}
public static Collection<MonsterType> spawnableMonstersAt(Location location) {
// It is impossible to spawn any monster without some empty space.
if (!location.getBlock().isPassable()) {
return Set.of();
}
// It is impossible to spawn any monster without a floor.
if (!location.getBlock().getRelative(BlockFace.DOWN).getType().isSolid()) {
return Set.of();
}
Set<MonsterType> spawnableMonsters = new HashSet<>();
// 1x2x1 monsters
if (location.getBlock().getRelative(BlockFace.UP).isPassable()) {
spawnableMonsters.add(MonsterType.CREEPER);
spawnableMonsters.add(MonsterType.SKELETON);
spawnableMonsters.add(MonsterType.ZOMBIE);
}
// 2x1x2 monsters
if (
// There is space for the monster to spawn
location.getBlock().getRelative(1, 0, 0).isPassable()
&& location.getBlock().getRelative(0, 0, 1).isPassable()
&& location.getBlock().getRelative(1, 0, 1).isPassable()
// There is a complete floor for it to spawn on
&& location.getBlock().getRelative(1, -1, 0).getType().isSolid()
&& location.getBlock().getRelative(0, -1, 1).getType().isSolid()
&& location.getBlock().getRelative(1, -1, 1).getType().isSolid()
) {
spawnableMonsters.add(MonsterType.SPIDER);
}
return spawnableMonsters;
}
/**
* Takes a number on the interval and maps it to another number of the interval according to the logistic curve.
*
* Graph: https://www.wolframalpha.com/input/?i=f%28x%29+%3D++e%5E%286%282x+-+1%29%29+%2F+%28e%5E%286%282x+-+1%29%29+%2B+1%29++from+0+to+1
*/
private static float logistic(float x) {
float eToX = (float) Math.pow(Math.E, 12 * x - 6);
return eToX / (eToX + 1);
}
public static float calculateWeightedLightLevel(Block block, float sunlightWeight, float moonlightWeight, float blocklightWeight) {
float weightedSkylight = MoonPhase.getMoonlight(block) * moonlightWeight + MoonPhase.getSunlight(block) * sunlightWeight;
float weightedBlocklight = block.getLightFromBlocks() * blocklightWeight;
return Math.max(weightedSkylight, weightedBlocklight);
}
public static float calculateWeightedLightLevelProbability(Block block, int maximumLight, float sunlightWeight, float moonlightWeight, float blocklightWeight) {
float weightedLightLevel = calculateWeightedLightLevel(block, sunlightWeight, moonlightWeight, blocklightWeight);
if (weightedLightLevel >= maximumLight) {
return 0.0f;
}
return 1 - logistic(weightedLightLevel / maximumLight);
}
public float calculateLightLevelProbability(MonsterType type, Block block) {
MonsterSpawnConfig cfg = monsters.get(type);
return calculateWeightedLightLevelProbability(block, cfg.maximumLightLevel(), cfg.sunlightWeight(), cfg.moonlightWeight(), cfg.blocklightWeight());
}
public float calculateLightLevelProbability(MonsterType type, Location location) {
return calculateLightLevelProbability(type, location.getBlock());
}
public float getMoonPhaseMultiplier(MonsterType type, MoonPhase phase) {
return monsters.get(type).phaseMultipliers().getOrDefault(phase, 1.0f);
}
public float getMoonPhaseMultiplier(MonsterType type, World world) {
return getMoonPhaseMultiplier(type, MoonPhase.fromWorld(world));
}
public float getMoonPhaseMultiplier(MonsterType type, Location location) {
return getMoonPhaseMultiplier(type, location.getWorld());
}
public float getMoonPhaseMultiplier(MonsterType type, Block block) {
return getMoonPhaseMultiplier(type, block.getWorld());
}
public static int countNearbyEntities(Class<? extends LivingEntity> clazz, Location location) {
return location.getWorld().getNearbyEntities(location, 50, 50, 50, e -> { return clazz.isInstance(e); }).size();
}
public static int countNearbyMonsters(Location location) {
return countNearbyEntities(Monster.class, location);
}
public static int countNearbyPlayers(Location location) {
return countNearbyEntities(Player.class, location);
}
public static float calculateNearbyEntitiesMultiplier(Location location) {
final int nearbyMonsters = countNearbyMonsters(location);
// TODO: Change to be a weighted value based on player distance.
final int nearbyPlayers = countNearbyPlayers(location);
// This algorithm is pretty ad-hoc. Don't overthink it.
// Basically, I want lots of nearby players to result in a higher spawn cap,
// but with diminishing returns so that more players is beneficial.
// (If it were linear, you would be at a serious disadvantage due to radiation effects and lag,
// and screwed unless all the other players were helping you fight.)
// It's also not a hard spawn cap, but past a point, the spawn rate needs to be severely diminished
// to prevent massive lag and infinitely-sized hordes.
final float idealMobQuantityMultiplier = (float) Math.max(0.5, Math.sqrt(nearbyPlayers)) * 25;
// constant factor of 2 allows some mobs to spawn beyond the ideal quantity,
// which in the logistic curve makes spawning mobs up to that point far more likely.
return 1 - logistic(nearbyMonsters / idealMobQuantityMultiplier / 2);
}
public static float calculateNearbyEntitiesMultiplier(Block block) {
return calculateNearbyEntitiesMultiplier(block.getLocation());
}
public float calculateSpawnProbability(MonsterType type, Location location) {
final float lightLevelProbability = calculateLightLevelProbability(type, location);
long time = location.getWorld().getTime();
float moonPhaseMultiplier = 0f;
if (time > 12000) { // dawn, dusk, or night
moonPhaseMultiplier = getMoonPhaseMultiplier(type, location);
if (time < 13000 || time > 23000) { // dawn or dusk
moonPhaseMultiplier /= 2;
}
}
final float nearbyEntitiesMultiplier = calculateNearbyEntitiesMultiplier(location);
return lightLevelProbability * moonPhaseMultiplier * nearbyEntitiesMultiplier;
}
public float calculateSpawnProbability(MonsterType type, Block block) {
return calculateSpawnProbability(type, block.getLocation());
}
public HashMap<MonsterType, Float> calculateSpawnProbabilities(Location location) {
HashMap<MonsterType, Float> spawnWeights = new HashMap<>();
for (MonsterType type : spawnableMonstersAt(location)) {
spawnWeights.put(type, calculateSpawnProbability(type, location));
}
return spawnWeights;
}
public HashMap<MonsterType, Float> calculateSpawnProbabilities(Block block) {
return calculateSpawnProbabilities(block.getLocation());
}
}

View File

@ -194,7 +194,7 @@ consoleRank:
description: The server console.
color: GOLD
decoration: BOLD
# The entity types which, if killed, will count towards your enlisted rank.
# Each value must be a valid Bukkit EntityType (e.g. `WITHER_SKELETON` or `ENDERMAN`),
# or one of my built-in entity collections (`bosses`, hostiles`, `monsters`, `neutrals`, `zombies`, `spiders`).
@ -208,5 +208,51 @@ consoleRank:
eligibleMobs: [monsters]
eligibleMobsName: monsters
spawns:
CREEPER:
# double spawns in the first and third quarter
phases:
THIRD_QUARTER: 2.0
FIRST_QUARTER: 2.0
# only spawn below 50 blocks, the lower the more frequent
height:
maximum: 50
SKELETON:
phases:
THIRD_QUARTER: 2.0
FIRST_QUARTER: 2.0
# only spawn above 80 blocks, the higher the more frequent
height:
minimum: 80
SPIDER:
light:
maximum: 5
weights:
# spawn up to light level 1
sun: 4.3
# spawn up to light level 3
moon: 1.5
# spawn up to light level 4
block: 1.2
# very strongly prefers dark moon phases
phases:
NEW: 3.0
WANING_GIBBOUS: 1.5
WAXING_GIBBOUS: 1.5
THIRD_QUARTER: 0.5
FIRST_QUARTER: 0.5
FULL: 0.3
ZOMBIE:
light:
weights:
# Zombies are only affected by the sky light level a quarter as much,
# meaning they can still sometimes spawn in broad daylight.
# They also slightly benefit from moonlight.
sun: 0.25
moon: -0.2
phases:
FULL: 2.0
WAXING_GIBBOUS: 1.5
WANING_GIBBOUS: 1.5
NEW: 0.5

View File

@ -31,6 +31,13 @@ commands:
permission: wasteland.official
permission-message: You are not a staff member with an officer rank.
# debug commands
debugspawnweights:
description: View the monster spawn weights at the block you are looking at.
usage: "Usage: /<command>"
permission: wasteland.debug.spawns.weights
permission-message: You do not have permission to view spawn debug information.
permissions:
wasteland.chat.officer:
description: Show a player's officer rank even in normal chat.
@ -65,3 +72,17 @@ permissions:
wasteland.view-eligible-mobs:
description: Allows you to see the specific list of what mobs count towards promotion with /rankeligiblemobs.
default: true
wasteland.debug:
description: Wasteland plugin debug commands.
default: false
children:
wasteland.debug.spawns: true
wasteland.debug.spawns:
description: Commands for debugging the spawning system.
default: false
children:
wasteland.debug.spawns.weights: true
wasteland.debug.spawns.weights:
description: Allows you to see the spawn weights by mob type at a block.
default: false