From 313fc8616ffba30f0af286209caadc1a3cc88b92 Mon Sep 17 00:00:00 2001 From: Jordan Irwin Date: Thu, 2 May 2024 02:35:18 -0700 Subject: [PATCH] Add support for dynamic creature spawning Position of spawn in zone is random instead of a static point https://github.com/arianne/stendhal/issues/713 --- data/conf/zones.xsd | 24 ++ doc/CHANGES.txt | 1 + .../server/core/config/ZonesXMLLoader.java | 10 + .../config/zone/CreatureSpawnsXMLReader.java | 56 +++ .../server/core/engine/StendhalRPZone.java | 52 +++ .../spawner/DynamicCreatureSpawner.java | 344 ++++++++++++++++++ 6 files changed, 487 insertions(+) create mode 100644 src/games/stendhal/server/core/config/zone/CreatureSpawnsXMLReader.java create mode 100644 src/games/stendhal/server/entity/mapstuff/spawner/DynamicCreatureSpawner.java diff --git a/data/conf/zones.xsd b/data/conf/zones.xsd index 1fd34be1f91..569b5553765 100644 --- a/data/conf/zones.xsd +++ b/data/conf/zones.xsd @@ -25,6 +25,7 @@ maxOccurs="unbounded"/> + @@ -178,4 +179,27 @@ + + + + + + + + + + + + + diff --git a/doc/CHANGES.txt b/doc/CHANGES.txt index dfdb560ed60..d1f478732cd 100644 --- a/doc/CHANGES.txt +++ b/doc/CHANGES.txt @@ -24,6 +24,7 @@ Changelog - new achievements: - 30 Minutes or Less: Deliver 25 hot pizzas - Balduin allows requesting different item for Ultimate Collector quest +- support added for dynamic creature spawning *web client* - fixed text extending past edge of notification bubbles diff --git a/src/games/stendhal/server/core/config/ZonesXMLLoader.java b/src/games/stendhal/server/core/config/ZonesXMLLoader.java index feaba63b608..637d676db84 100644 --- a/src/games/stendhal/server/core/config/ZonesXMLLoader.java +++ b/src/games/stendhal/server/core/config/ZonesXMLLoader.java @@ -38,6 +38,7 @@ import games.stendhal.common.tiled.StendhalMapStructure; import games.stendhal.server.core.config.zone.AttributesXMLReader; import games.stendhal.server.core.config.zone.ConfiguratorXMLReader; +import games.stendhal.server.core.config.zone.CreatureSpawnsXMLReader; import games.stendhal.server.core.config.zone.EntitySetupXMLReader; import games.stendhal.server.core.config.zone.PortalSetupXMLReader; import games.stendhal.server.core.config.zone.RegionNameSubstitutionHelper; @@ -60,6 +61,8 @@ public final class ZonesXMLLoader { /** Zone attributes reader. */ private static final SetupXMLReader attributesReader = new AttributesXMLReader(); + /** Dynamically spawned creatures. */ + private static final SetupXMLReader creatureSpawnsReader = new CreatureSpawnsXMLReader(); /** * The ConfiguratorDescriptor XML reader. */ @@ -419,6 +422,13 @@ public ZoneDesc readZone(final Element element) { continue; } else if (tag.equals("associated")) { desc.setAssociatedZones(child.getAttribute("zones")); + } else if (tag.equals("spawns")) { + for (final Element spawn: XMLUtil.getElements(child)) { + final String spawnType = spawn.getTagName(); + if ("creatures".equals(spawnType)) { + setupDesc = creatureSpawnsReader.read(spawn); + } + } } else { logger.warn("Zone [" + name + "] has unknown element: " + tag); continue; diff --git a/src/games/stendhal/server/core/config/zone/CreatureSpawnsXMLReader.java b/src/games/stendhal/server/core/config/zone/CreatureSpawnsXMLReader.java new file mode 100644 index 00000000000..e022d93c166 --- /dev/null +++ b/src/games/stendhal/server/core/config/zone/CreatureSpawnsXMLReader.java @@ -0,0 +1,56 @@ +/*************************************************************************** + * Copyright © 2024 - Faiumoni e. V. * + *************************************************************************** + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +package games.stendhal.server.core.config.zone; + +import java.util.Map; + +import org.apache.log4j.Logger; +import org.w3c.dom.Element; + +import games.stendhal.common.MathHelper; +import games.stendhal.server.core.engine.StendhalRPZone; +import games.stendhal.server.entity.mapstuff.spawner.DynamicCreatureSpawner; + + +/** + * Parses info for dynamically spawned creatures. + */ +public class CreatureSpawnsXMLReader extends SetupXMLReader { + + private static Logger logger = Logger.getLogger(CreatureSpawnsXMLReader.class); + + + @Override + public SetupDescriptor read(final Element element) { + final CreatureSpawnsDescriptor desc = new CreatureSpawnsDescriptor(); + readParameters(desc, element); + return desc; + } + + private static class CreatureSpawnsDescriptor extends SetupDescriptor { + @Override + public void setup(final StendhalRPZone zone) { + final DynamicCreatureSpawner spawner = new DynamicCreatureSpawner(zone); + final Map params = getParameters(); + for (final String name: params.keySet()) { + final int max = MathHelper.parseIntDefault(params.get(name), 0); + if (max < 0) { + logger.warn("Max must be a positive integer, not registering dynamic spawn for creature \"" + name + "\""); + continue; + } + spawner.register(name, max); + } + // NOTE: does zone need to know about dynamic spawner? + zone.setDynamicSpawner(spawner); + } + } +} diff --git a/src/games/stendhal/server/core/engine/StendhalRPZone.java b/src/games/stendhal/server/core/engine/StendhalRPZone.java index e7081d73835..d32d5d520de 100644 --- a/src/games/stendhal/server/core/engine/StendhalRPZone.java +++ b/src/games/stendhal/server/core/engine/StendhalRPZone.java @@ -39,6 +39,7 @@ import games.stendhal.common.Direction; import games.stendhal.common.Line; import games.stendhal.common.MathHelper; +import games.stendhal.common.Rand; import games.stendhal.common.filter.FilterCriteria; import games.stendhal.common.grammar.Grammar; import games.stendhal.common.tiled.LayerDefinition; @@ -64,6 +65,7 @@ import games.stendhal.server.entity.mapstuff.portal.OneWayPortalDestination; import games.stendhal.server.entity.mapstuff.portal.Portal; import games.stendhal.server.entity.mapstuff.spawner.CreatureRespawnPoint; +import games.stendhal.server.entity.mapstuff.spawner.DynamicCreatureSpawner; import games.stendhal.server.entity.mapstuff.spawner.PassiveEntityRespawnPoint; import games.stendhal.server.entity.mapstuff.spawner.PassiveEntityRespawnPointFactory; import games.stendhal.server.entity.mapstuff.spawner.SheepFood; @@ -73,6 +75,7 @@ import games.stendhal.server.entity.npc.TrainingDummyFactory; import games.stendhal.server.entity.player.Player; import games.stendhal.server.util.StringUtils; +import marauroa.common.Pair; import marauroa.common.game.IRPZone; import marauroa.common.game.RPObject; import marauroa.common.game.RPSlot; @@ -113,8 +116,12 @@ public class StendhalRPZone extends MarauroaRPZone { */ private final List sheepFoods; + /** Statically spawned creatures. */ private final List respawnPoints; + /** Dynamically spawned creatures. */ + private DynamicCreatureSpawner dynamicSpawner; + private final List plantGrowers; private final List playersAndFriends; @@ -311,6 +318,30 @@ public void remove(final CreatureRespawnPoint point) { respawnPoints.remove(point); } + /** + * Retrieves the dynamic creature spawner associated with this zone. + * + * @return + * Creature spawner instance. + */ + public DynamicCreatureSpawner getDynamicSpawner() { + return dynamicSpawner; + } + + /** + * Sets the dynamic creature spawner associated with this zone. + * + * @param spawner + * Creature spawner instance. + */ + public void setDynamicSpawner(final DynamicCreatureSpawner spawner) { + if (dynamicSpawner != null) { + dynamicSpawner.remove(); + } + dynamicSpawner = spawner; + dynamicSpawner.init(); + } + /** * Retrieves growers in this zone. */ @@ -1517,6 +1548,7 @@ public void nextTurn() { os.append("playersAndFriends: " + playersAndFriends.size() + "\n"); os.append("portals: " + portals.size() + "\n"); os.append("respawnPoints: " + respawnPoints.size() + "\n"); + os.append("dynamicRespawns: " + dynamicSpawner.maxTotal() + "\n"); os.append("sheepFoods: " + sheepFoods.size() + "\n"); os.append("objects: " + objects.size() + "\n"); logger.info(os); @@ -1971,4 +2003,24 @@ public boolean passes(final Entity e) { } return (WeatherEntity) entities.get(0); } + + /** + * Attempts to find a position where an entity can be spawned. + * + * @param entity + * Entity attempting to spawn. + * @return + * Appropriate position or `null` if none found. + */ + public Pair getRandomSpawnPosition(final Entity entity) { + final short retries = 50; + for (short t = 0; t < retries; t++) { + final int x = Rand.rand(getWidth()); + final int y = Rand.rand(getHeight()); + if (!collides(entity, x, y)) { + return new Pair<>(x, y); + } + } + return null; + } } diff --git a/src/games/stendhal/server/entity/mapstuff/spawner/DynamicCreatureSpawner.java b/src/games/stendhal/server/entity/mapstuff/spawner/DynamicCreatureSpawner.java new file mode 100644 index 00000000000..564b1f7895e --- /dev/null +++ b/src/games/stendhal/server/entity/mapstuff/spawner/DynamicCreatureSpawner.java @@ -0,0 +1,344 @@ +/*************************************************************************** + * Copyright © 2024 - Faiumoni e. V. * + *************************************************************************** + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +package games.stendhal.server.entity.mapstuff.spawner; + +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; + +import org.apache.log4j.Logger; + +import games.stendhal.common.MathHelper; +import games.stendhal.common.Rand; +import games.stendhal.common.Triple; +import games.stendhal.server.core.engine.SingletonRepository; +import games.stendhal.server.core.engine.StendhalRPZone; +import games.stendhal.server.core.rp.StendhalRPAction; +import games.stendhal.server.core.rule.defaultruleset.DefaultCreature; +import games.stendhal.server.entity.creature.Creature; +import games.stendhal.server.util.Observer; +import marauroa.common.Pair; + + +/** + * Class that spawn multiple creatures at random points in a zone. + */ +public class DynamicCreatureSpawner extends LinkedHashMap> implements CreatureSpawner { + + private static final Logger logger = Logger.getLogger(DynamicCreatureSpawner.class); + + /** Zone associated with this spawner. */ + private final StendhalRPZone zone; + /** Creatures to be spawned at turn reached. */ + final List> queued; + + final List observers; + + /** Denotes whether turn notifier is active. */ + private int spawnsAt = -1; + + + /** + * Creates a new spawner. + * + * @param zone + */ + public DynamicCreatureSpawner(final StendhalRPZone zone) { + this.zone = zone; + this.observers = new LinkedList(); + this.queued = new LinkedList>(); + } + + /** + * Starts the spawning cycle of all registered creatures. + */ + public void init() { + for (final String name: keySet()) { + queue(name); + } + // NOTE: Do we want to queue all possible spawns of a creature simultaneously instead of + // sequentially? E.g. if max active = 10 queue up all 10 instances instead of only the + // first. + startNotifier(); + } + + /** + * Disables associated turn notifiers for clean removal. + */ + public void remove() { + SingletonRepository.getTurnNotifier().dontNotify(this); + // NOTE: should active spawned creatures also be removed? + } + + /** + * Adds a creature to spawn list. + * + * Registration is represented in a map indexed by creature name. The value is a triple of + * consisting of the following integer representations: + * - first: respawn time + * - second: max active + * - third: actual acitve + * + * @param name + * Creature name. + * @param max + * Maximum instances allowed at one time from this spawner. + */ + public void register(final String name, final int max) { + if (max < 1) { + logger.warn("Max must be a positive integer"); + return; + } + final DefaultCreature creature = SingletonRepository.getEntityManager().getDefaultCreature(name); + if (creature == null) { + logger.warn("Creature \"" + name + "\" not found"); + return; + } + put(name, new Triple(creature.getRespawnTime(), max, 0)); + } + + /** + * Retrieves respawn time value of a creature. + * + * @param name + * Creature name. + * @return + * Rate at which creature respawns. + */ + private int getRespawnTime(final String name) { + if (containsKey(name)) { + return get(name).getFirst(); + } + return 0; + } + + /** + * Retrieves maximum allowed active spawned instances. + * + * @param name + * Creature name. + * @return + * Maximum allowed creatures at one time. + */ + private int getActiveMax(final String name) { + if (containsKey(name)) { + return get(name).getSecond(); + } + return 0; + } + + /** + * Retrieves the current number of active creatures from this spawner. + * + * @param name + * Creature name. + * @return + * Active creatures. + */ + private int getActiveCount(final String name) { + if (containsKey(name)) { + return get(name).getThird(); + } + return 0; + } + + /** + * Retrieves the total number of active creatures that can be spawned at the same time. + * + * @return + * Max total active creatures. + */ + public int maxTotal() { + int total = 0; + for (final Triple tr: values()) { + total += tr.getSecond(); + } + return total; + } + + /** + * Adds a new creature instance to zone at a random position. + * + * @param name + * Creature name. + */ + private boolean spawn(final String name) { + final DefaultCreature defaultCreature = SingletonRepository.getEntityManager().getDefaultCreature(name); + if (defaultCreature == null) { + logger.error("Default creature \"" + name + "\" not found"); + return false; + } + final Creature creature = defaultCreature.getCreatureRandomizeStats(); + try { + // find a suitable location on map for spawning + final Pair pos = zone.getRandomSpawnPosition(creature); + if (pos == null) { + throw new Exception("No suitable position for dynamic spawning available in zone " + zone.getName()); + } + final int x = pos.first(); + final int y = pos.second(); + // NOTE: behavior copied from `CreatureRespawnPoint`, not sure what it does + creature.registerObjectsForNotification(observers); + if (StendhalRPAction.placeat(zone, creature, x, y)) { + onSpawned(creature); + return true; + } else { + // could not place the creature anywhere so treat it like it died + onRemoved(creature); + logger.warn("Could not spawn " + creature.getName() + " near " + zone.getName() + " " + x + " " + y); + } + } catch (final Exception e) { + logger.error("Error spawning entity " + creature, e); + } + return false; + } + + /** + * Adds creature to spawn queue. + * + * @param name + * Creature name. + */ + private void queue(final String name) { + if (getActiveCount(name) >= getActiveMax(name)) { + // maximum number of creatures is active so don't spawn more + return; + } + final int nextTurn = MathHelper.clamp(Rand.randExponential(getRespawnTime(name)), MIN_RESPAWN_TIME, MAX_RESPAWN_TIME); + queued.add(new Pair<>(nextTurn, name)); + } + + /** + * Removes next entry from the queue. + */ + private void unqueue() { + final int idx = queued.indexOf(getNextQueued(false)); + if (idx > -1) { + queued.remove(idx); + } + } + + /** + * Gets the next entry in the queue. + * + * @param copy + * If {@code true} make a copy before returning. + * @return + * Attributes of next entry. + */ + private Pair getNextQueued(final boolean copy) { + Pair next = null; + // NOTE: might be more efficient to get first value of sorted list + for (final Pair p: queued) { + final int t = p.first(); + if (next == null || t < next.first()) { + next = p; + } + } + if (copy && next != null) { + return new Pair<>(next.first(), next.second()); + } + return next; + } + + /** + * Increments the known number of active creature instances. + * + * @param name + * Creature name. + * @param amount + * Number to adjust by (usually 1 or -1). + */ + private void incActive(final String name, final short amount) { + if (!containsKey(name)) { + logger.warn("Creature \"" + name + "\" not registered"); + return; + } + final Triple oldValue = get(name); + put(name, new Triple(oldValue.getFirst(), oldValue.getSecond(), oldValue.getThird() + amount)); + } + + @Override + public void onSpawned(final Creature spawned) { + spawned.init(); + spawned.setSpawner(this); + final String name = spawned.getName(); + incActive(name, (short) 1); + // spawning succeeded so reset active spawning property + spawnsAt = -1; + // add another to queue + queue(name); + // don't restart notifier here as it will be done in {@code TurnListener.onTurnReached} + } + + @Override + public void onRemoved(final Creature removed) { + final String name = removed.getName(); + incActive(name, (short) -1); + // add another to queue + queue(name); + // restart notifier if needed + startNotifier(); + } + + /** + * Starts turn notifier for next queued entry. + */ + private void startNotifier() { + final Pair next = getNextQueued(false); + if (next == null) { + return; + } + final int turns = next.first(); + if (spawnsAt > -1 && turns >= spawnsAt) { + // don't override earlier spawning + return; + } + spawnsAt = turns; + SingletonRepository.getTurnNotifier().notifyInTurns(turns, this); + } + + @Override + public void onTurnReached(final int currentTurn) { + final Pair next = getNextQueued(true); + if (next != null) { + final String nextName = next.second(); + /* this check doesn't work because currentTurn is total turns since server started & + * next.first() is turns since time of queue + if (next.first() != currentTurn) { + logger.warn("Creature \"" + nextName + "\" scheduled to spawn at turn " + nextTurn + " but spawning at " + currentTurn); + } + */ + // remove from queue + unqueue(); + spawn(nextName); + } else { + logger.warn("Turn reached but nothing queued for spawn"); + } + + // restart notifier if another entry is in queue + startNotifier(); + if (spawnsAt > -1 && queued.size() > 0) { + logger.warn("Queue not empty but not scheduled for spawn"); + } + } + + @Override + public void addObserver(final Observer observer) { + observers.add(observer); + } + + @Override + public void removeObserver(final Observer observer) { + observers.remove(observer); + } +}