package squidpony.squidgrid.mapping.locks.generators;
import squidpony.squidgrid.Direction;
import squidpony.squidgrid.mapping.locks.*;
import squidpony.squidgrid.mapping.locks.Condition.SwitchState;
import squidpony.squidgrid.mapping.locks.constraints.ILayoutConstraints;
import squidpony.squidgrid.mapping.locks.util.GenerationFailureException;
import squidpony.squidmath.Coord;
import squidpony.squidmath.IntVLA;
import squidpony.squidmath.RNG;
import java.util.*;
/**
* The default and reference implementation of an {@link ILayoutGenerator}.
*/
public class LayoutGenerator implements ILayoutGenerator {
public static final int MAX_RETRIES = 20;
protected RNG random;
protected RoomLayout dungeon;
protected ILayoutConstraints constraints;
protected boolean bossRoomLocked, generateGoal;
/**
* Creates a LayoutGenerator with a given random seed and places
* specific constraints on {@link IRoomLayout}s it generates.
*
* @param rng the random number generator to use
* @param constraints the constraints to place on generation
* @see ILayoutConstraints
*/
public LayoutGenerator(RNG rng,
ILayoutConstraints constraints) {
this.random = rng;
assert constraints != null;
this.constraints = constraints;
bossRoomLocked = generateGoal = true;
}
/**
* Randomly chooses a {@link Room} within the given collection that has at
* least one adjacent empty space.
*
* @param roomCollection the collection of rooms to choose from
* @return the room that was chosen, or null if there are no rooms with
* adjacent empty spaces
*/
protected Room chooseRoomWithFreeEdge(Collection<Room> roomCollection,
int keyLevel) {
List<Room> rooms = new ArrayList<Room>(roomCollection);
random.shuffle(rooms);
Room room;
IntVLA near;
for (int i = 0; i < rooms.size(); ++i) {
room = rooms.get(i);
near = constraints.getAdjacentRooms(room.id, keyLevel);
for (int j = 0; j < near.size; j++) {
if (dungeon.get(near.get(j)) == null) {
return room;
}
}
}
return null;
}
/**
* Randomly chooses a {@link Direction} in which the given {@link Room} has
* an adjacent empty space.
*
* @param room the room
* @return the Direction of the empty space chosen adjacent to the Room or
* null if there are no adjacent empty spaces
*/
protected int chooseFreeEdge(Room room, int keyLevel) {
IntVLA neighbors = new IntVLA(constraints.getAdjacentRooms(room.id, keyLevel));
neighbors.shuffle(random);
while (neighbors.size > 0) {
int choice = neighbors.getRandomElement(random);
if (dungeon.get(choice) == null)
return choice;
neighbors.removeValue(choice);
}
assert false;
throw new GenerationFailureException("Internal error: Room doesn't have a free edge");
}
/**
* Maps 'keyLevel' to the set of rooms within that keyLevel.
* <p>
* A 'keyLevel' is the count of the number of unique keys are needed for all
* the locks we've placed. For example, all the rooms in keyLevel 0 are
* accessible without collecting any keys, while to get to rooms in
* keyLevel 3, the player must have collected at least 3 keys.
*/
protected class KeyLevelRoomMapping {
protected List<List<Room>> map = new ArrayList<List<Room>>(
constraints.getMaxKeys());
List<Room> getRooms(int keyLevel) {
while (keyLevel >= map.size()) map.add(null);
if (map.get(keyLevel) == null)
map.set(keyLevel, new ArrayList<Room>());
return map.get(keyLevel);
}
void addRoom(int keyLevel, Room room) {
getRooms(keyLevel).add(room);
}
int keyCount() {
return map.size();
}
}
/**
* Thrown by several ILayoutGenerator methods that can fail.
* Should be caught and handled in {@link #generate}.
*/
protected static class RetryException extends Exception {
private static final long serialVersionUID = 1L;
}
protected static class OutOfRoomsException extends Exception {
private static final long serialVersionUID = 1L;
}
/**
* Comparator objects for sorting {@link Room}s in a couple of different
* ways. These are used to determine in which rooms of a given keyLevel it
* is best to place the next key.
*
* @see #placeKeys
*/
protected static final Comparator<Room>
EDGE_COUNT_COMPARATOR = new Comparator<Room>() {
@Override
public int compare(Room arg0, Room arg1) {
return arg0.linkCount() - arg1.linkCount();
}
},
INTENSITY_COMPARATOR = new Comparator<Room>() {
@Override
public int compare(Room arg0, Room arg1) {
return arg0.getIntensity() > arg1.getIntensity() ? -1
: arg0.getIntensity() < arg1.getIntensity() ? 1
: 0;
}
};
/**
* Sets up the dungeon's entrance room.
*
* @param levels the keyLevel -> room-set mapping to update
* @see KeyLevelRoomMapping
*/
protected void initEntranceRoom(KeyLevelRoomMapping levels)
throws RetryException {
int id;
IntVLA possibleEntries = constraints.initialRooms();
assert possibleEntries.size > 0;
id = possibleEntries.getRandomElement(random);
Room entry = new Room(id, constraints.getCoords(id), null, Symbol.START, new Condition());
dungeon.add(entry);
levels.addRoom(0, entry);
}
/**
* Decides whether to add a new lock (and keyLevel) at this point.
*
* @param keyLevel the number of distinct locks that have been placed into
* the map so far
* @param numRooms the number of rooms at the current keyLevel
* @param targetRoomsPerLock the number of rooms the generator has chosen
* as the target number of rooms to place at each keyLevel (which
* subclasses can ignore, if desired).
*/
protected boolean shouldAddNewLock(int keyLevel, int numRooms, int targetRoomsPerLock) {
int usableKeys = constraints.getMaxKeys();
if (isBossRoomLocked())
usableKeys -= 1;
return numRooms >= targetRoomsPerLock && keyLevel < usableKeys;
}
/**
* Fill the dungeon's space with rooms and doors (some locked).
* Keys are not inserted at this point.
*
* @param levels the keyLevel -> room-set mapping to update
* @throws RetryException if it fails
* @see KeyLevelRoomMapping
*/
protected void placeRooms(KeyLevelRoomMapping levels, int roomsPerLock)
throws RetryException, OutOfRoomsException {
// keyLevel: the number of keys required to get to the new room
int keyLevel = 0;
int latestKey = Symbol.NOTHING;
// condition that must hold true for the player to reach the new room
// (the set of keys they must have).
Condition cond = new Condition();
// Loop to place rooms and link them
while (dungeon.roomCount() < constraints.getMaxRooms()) {
boolean doLock = false;
// Decide whether we need to place a new lock
// (Don't place the last lock, since that's reserved for the boss)
if (shouldAddNewLock(keyLevel, levels.getRooms(keyLevel).size(), roomsPerLock)) {
latestKey = keyLevel++;
cond = cond.and(latestKey);
doLock = true;
}
// Find an existing room with a free edge:
Room parentRoom = null;
if (!doLock && random.nextIntHasty(10) > 0)
parentRoom = chooseRoomWithFreeEdge(levels.getRooms(keyLevel),
keyLevel);
if (parentRoom == null) {
parentRoom = chooseRoomWithFreeEdge(dungeon.getRooms(),
keyLevel);
doLock = true;
}
if (parentRoom == null)
throw new OutOfRoomsException();
// Decide which direction to put the new room in relative to the
// parent
int nextId = chooseFreeEdge(parentRoom, keyLevel);
Set<Coord> coords = constraints.getCoords(nextId);
Room room = new Room(nextId, coords, parentRoom, Symbol.NOTHING, cond);
// Add the room to the dungeon
assert dungeon.get(room.id) == null;
//synchronized(dungeon) {
dungeon.add(room);
parentRoom.addChild(room);
dungeon.link(parentRoom, room, doLock ? latestKey : Symbol.NOTHING);
// }
levels.addRoom(keyLevel, room);
}
}
/**
* Places the BOSS and GOAL rooms within the dungeon, in existing rooms.
* These rooms are moved into the next keyLevel.
*
* @param levels the keyLevel -> room-set mapping to update
* @throws RetryException if it fails
* @see KeyLevelRoomMapping
*/
protected void placeBossGoalRooms(KeyLevelRoomMapping levels)
throws RetryException {
List<Room> possibleGoalRooms = new ArrayList<Room>(dungeon.roomCount());
int goalSym = Symbol.GOAL,
bossSym = Symbol.BOSS;
for (Room room: dungeon.getRooms()) {
if (room.getChildren().size() > 0 || room.getItem() != Symbol.NOTHING)
continue;
Room parent = room.getParent();
if (parent == null || parent.getChildren().size() != 1 ||
room.getItem() != Symbol.NOTHING ||
!parent.getPrecond().implies(room.getPrecond()))
continue;
if (isGenerateGoal()) {
if (!constraints.roomCanFitItem(room.id, goalSym) ||
!constraints.roomCanFitItem(parent.id, bossSym))
continue;
} else {
if (!constraints.roomCanFitItem(room.id, bossSym))
continue;
}
possibleGoalRooms.add(room);
}
if (possibleGoalRooms.size() == 0) throw new RetryException();
Room goalRoom = random.getRandomElement(possibleGoalRooms),
bossRoom = goalRoom.getParent();
if (!isGenerateGoal()) {
bossRoom = goalRoom;
goalRoom = null;
}
if (goalRoom != null) goalRoom.setItem(goalSym);
bossRoom.setItem(bossSym);
if (isBossRoomLocked()) {
int oldKeyLevel = bossRoom.getPrecond().getKeyLevel(),
newKeyLevel = Math.min(levels.keyCount(), constraints.getMaxKeys());
List<Room> oklRooms = levels.getRooms(oldKeyLevel);
if (goalRoom != null) oklRooms.remove(goalRoom);
oklRooms.remove(bossRoom);
if (goalRoom != null) levels.addRoom(newKeyLevel, goalRoom);
levels.addRoom(newKeyLevel, bossRoom);
int bossKey = newKeyLevel-1;
Condition precond = bossRoom.getPrecond().and(bossKey);
bossRoom.setPrecond(precond);
if (goalRoom != null) goalRoom.setPrecond(precond);
if (newKeyLevel == 0) {
dungeon.link(bossRoom.getParent(), bossRoom);
} else {
dungeon.link(bossRoom.getParent(), bossRoom, bossKey);
}
if (goalRoom != null) dungeon.link(bossRoom, goalRoom);
}
}
/**
* Removes the given {@link Room} and all its descendants from the given
* list.
*
* @param rooms the list of Rooms to remove nodes from
* @param room the Room whose descendants to remove from the list
*/
protected void removeDescendantsFromList(List<Room> rooms, Room room) {
rooms.remove(room);
for (Room child: room.getChildren()) {
removeDescendantsFromList(rooms, child);
}
}
/**
* Adds extra conditions to the given {@link Room}'s preconditions and all
* of its descendants.
*
* @param room the Room to add extra preconditions to
* @param cond the extra preconditions to add
*/
protected void addPrecond(Room room, Condition cond) {
room.setPrecond(room.getPrecond().and(cond));
for (Room child: room.getChildren()) {
addPrecond(child, cond);
}
}
/**
* Randomly locks descendant rooms of the given {@link Room} with
* {@link Edge}s that require the switch to be in the given state.
* <p>
* If the given state is EITHER, the required states will be random.
*
* @param room the room whose child to lock
* @param givenState the state to require the switch to be in for the
* child rooms to be accessible
* @return true if any locks were added, false if none were
* added (which can happen due to the way the random
* decisions are made)
* @see SwitchState
*/
protected boolean switchLockChildRooms(Room room,
SwitchState givenState) {
boolean anyLocks = false;
SwitchState state = givenState != SwitchState.EITHER
? givenState
: (random.nextBoolean()
? SwitchState.ON
: SwitchState.OFF);
for (Edge edge: room.getEdges()) {
int neighborId = edge.getTargetRoomId();
Room nextRoom = dungeon.get(neighborId);
if (room.getChildren().contains(nextRoom)) {
if (room.getEdge(neighborId).getSymbol() == Symbol.NOTHING &&
random.nextIntHasty(4) != 0) {
dungeon.link(room, nextRoom, state.toSymbol());
addPrecond(nextRoom, new Condition(state.toSymbol()));
anyLocks = true;
} else {
anyLocks |= switchLockChildRooms(nextRoom, state);
}
if (givenState == SwitchState.EITHER) {
state = state.invert();
}
}
}
return anyLocks;
}
/**
* Returns a path from the goal to the dungeon entrance, along the 'parent'
* relations.
*
* @return a list of linked {@link Room}s starting with the goal room and
* ending with the start room.
*/
protected List<Room> getSolutionPath() {
List<Room> solution = new ArrayList<Room>();
Room room = dungeon.findGoal();
while (room != null) {
solution.add(room);
room = room.getParent();
}
return solution;
}
/**
* Makes some {@link Edge}s within the dungeon require the dungeon's switch
* to be in a particular state, and places the switch in a room in the
* dungeon.
*
* @throws RetryException if it fails
*/
protected void placeSwitches() throws RetryException {
// Possible TODO: have multiple switches on separate circuits
// At the moment, we only have one switch per dungeon.
if (constraints.getMaxSwitches() <= 0) return;
List<Room> solution = getSolutionPath();
for (int attempt = 0; attempt < 10; ++attempt) {
List<Room> rooms = new ArrayList<Room>(dungeon.getRooms());
random.shuffle(rooms);
random.shuffle(solution);
// Pick a base room from the solution path so that the player
// will have to encounter a switch-lock to solve the dungeon.
Room baseRoom = null;
for (Room room: solution) {
if (room.getChildren().size() > 1 && room.getParent() != null) {
baseRoom = room;
break;
}
}
if (baseRoom == null) throw new RetryException();
Condition baseRoomCond = baseRoom.getPrecond();
removeDescendantsFromList(rooms, baseRoom);
int switchSym = Symbol.SWITCH;
Room switchRoom = null;
for (Room room: rooms) {
if (room.getItem() == Symbol.NOTHING &&
baseRoomCond.implies(room.getPrecond()) &&
constraints.roomCanFitItem(room.id, switchSym)) {
switchRoom = room;
break;
}
}
if (switchRoom == null) continue;
if (switchLockChildRooms(baseRoom, SwitchState.EITHER)) {
switchRoom.setItem(switchSym);
return;
}
}
throw new RetryException();
}
/**
* Randomly links up some adjacent rooms to make the dungeon graph less of
* a tree.
*
* @throws RetryException if it fails
*/
protected void graphify() throws RetryException {
IntVLA near;
for (Room room: dungeon.getRooms()) {
if (room.isGoal() || room.isBoss()) continue;
near = constraints.getAdjacentRooms(room.id, Integer.MAX_VALUE);
for (int i = 0; i < near.size; i++) {
// Doesn't matter what the keyLevel is; later checks about
// preconds ensure linkage doesn't trivialize the puzzle.
int nextId = near.get(i);
if (room.getEdge(nextId) != null) continue;
Room nextRoom = dungeon.get(nextId);
if (nextRoom == null || nextRoom.isGoal() || nextRoom.isBoss())
continue;
boolean forwardImplies = room.getPrecond().implies(nextRoom.getPrecond()),
backwardImplies = nextRoom.getPrecond().implies(room.getPrecond());
if (forwardImplies && backwardImplies) {
// both rooms are at the same keyLevel.
if (random.nextDouble() >=
constraints.edgeGraphifyProbability(room.id, nextRoom.id))
continue;
dungeon.link(room, nextRoom);
} else {
int difference = room.getPrecond().singleSymbolDifference(
nextRoom.getPrecond());
if (difference == Symbol.NOTHING || (!Symbol.isSwitchState(difference) &&
random.nextDouble() >=
constraints.edgeGraphifyProbability(room.id, nextRoom.id)))
continue;
dungeon.link(room, nextRoom, difference);
}
}
}
}
/**
* Places keys within the dungeon in such a way that the dungeon is
* guaranteed to be solvable.
*
* @param levels the keyLevel -> room-set mapping to use
* @throws RetryException if it fails
* @see KeyLevelRoomMapping
*/
protected void placeKeys(KeyLevelRoomMapping levels) throws RetryException {
// Now place the keys. For every key-level but the last one, place a
// key for the next level in it, preferring rooms with fewest links
// (dead end rooms).
for (int key = 0; key < levels.keyCount()-1; ++key) {
List<Room> rooms = levels.getRooms(key);
random.shuffle(rooms);
// Collections.sort is stable: it doesn't reorder "equal" elements,
// which means the shuffling we just did is still useful.
Collections.sort(rooms, INTENSITY_COMPARATOR);
// Alternatively, use the EDGE_COUNT_COMPARATOR to put keys at
// 'dead end' rooms.
boolean placedKey = false;
for (Room room: rooms) {
if (room.getItem() == Symbol.NOTHING && constraints.roomCanFitItem(room.id, key)) {
room.setItem(key);
placedKey = true;
break;
}
}
if (!placedKey)
// there were no rooms into which the key would fit
throw new RetryException();
}
}
protected static final double
INTENSITY_GROWTH_JITTER = 0.1,
INTENSITY_EASE_OFF = 0.2;
/**
* Recursively applies the given intensity to the given {@link Room}, and
* higher intensities to each of its descendants that are within the same
* keyLevel.
* <p>
* Intensities set by this method may (will) be outside of the normal range
* from 0.0 to 1.0. See {@link #normalizeIntensity} to correct this.
*
* @param room the room to set the intensity of
* @param intensity the value to set intensity to (some randomn variance is
* added)
* @see Room
*/
protected double applyIntensity(Room room, double intensity) {
intensity *= 1.0 - INTENSITY_GROWTH_JITTER/2.0 +
INTENSITY_GROWTH_JITTER * random.nextDouble();
room.setIntensity(intensity);
double maxIntensity = intensity;
for (Room child: room.getChildren()) {
if (room.getPrecond().implies(child.getPrecond())) {
maxIntensity = Math.max(maxIntensity, applyIntensity(child,
intensity + 1.0));
}
}
return maxIntensity;
}
/**
* Scales intensities within the dungeon down so that they all fit within
* the range 0 <= intensity < 1.0.
*
* @see Room
*/
protected void normalizeIntensity() {
double maxIntensity = 0.0;
for (Room room: dungeon.getRooms()) {
maxIntensity = Math.max(maxIntensity, room.getIntensity());
}
for (Room room: dungeon.getRooms()) {
room.setIntensity(room.getIntensity() * 0.99 / maxIntensity);
}
}
/**
* Computes the 'intensity' of each {@link Room}. Rooms generally get more
* intense the deeper they are into the dungeon.
*
* @param levels the keyLevel -> room-set mapping to update
* @throws RetryException if it fails
* @see KeyLevelRoomMapping
* @see Room
*/
protected void computeIntensity(KeyLevelRoomMapping levels)
throws RetryException {
double nextLevelBaseIntensity = 0.0;
for (int level = 0; level < levels.keyCount(); ++level) {
double intensity = nextLevelBaseIntensity *
(1.0 - INTENSITY_EASE_OFF);
for (Room room: levels.getRooms(level)) {
if (room.getParent() == null ||
!room.getParent().getPrecond().
implies(room.getPrecond())) {
nextLevelBaseIntensity = Math.max(
nextLevelBaseIntensity,
applyIntensity(room, intensity));
}
}
}
normalizeIntensity();
dungeon.findBoss().setIntensity(1.0);
Room goalRoom = dungeon.findGoal();
if (goalRoom != null)
goalRoom.setIntensity(0.0);
}
/**
* Checks with the
* {@link ILayoutConstraints} that the
* dungeon is OK to use.
*
* @throws RetryException if the ILayoutConstraints decided generation must
* be re-attempted
* @see ILayoutConstraints
*/
protected void checkAcceptable() throws RetryException {
if (!constraints.isAcceptable(dungeon))
throw new RetryException();
}
@Override
public void generate() {
int attempt = 0;
while (true) {
try {
KeyLevelRoomMapping levels;
int roomsPerLock;
if (constraints.getMaxKeys() > 0) {
roomsPerLock = constraints.getMaxRooms() /
constraints.getMaxKeys();
} else {
roomsPerLock = constraints.getMaxRooms();
}
while (true) {
dungeon = new RoomLayout();
// Maps keyLevel -> Rooms that were created when lockCount had that
// value
levels = new KeyLevelRoomMapping();
// Create the entrance to the dungeon:
initEntranceRoom(levels);
try {
// Fill the dungeon with rooms:
placeRooms(levels, roomsPerLock);
break;
} catch (OutOfRoomsException e) {
// We can run out of rooms where certain links have
// predetermined locks. Example: if a river bisects the
// map, the keyLevel for rooms in the river > 0 because
// crossing water requires a key. If there are not
// enough rooms before the river to build up to the
// key for the river, we've run out of rooms.
//log("Ran out of rooms. roomsPerLock was "+roomsPerLock);
roomsPerLock = roomsPerLock * constraints.getMaxKeys() /
(constraints.getMaxKeys() + 1);
//log("roomsPerLock is now "+roomsPerLock);
if (roomsPerLock == 0) {
throw new GenerationFailureException(
"Failed to place rooms. Have you forgotten to disable boss-locking?");
// If the boss room is locked, the final key is used
// only for the boss room. So if the final key is
// also used to cross the river, rooms cannot be
// placed.
}
}
}
// Place the boss and goal rooms:
placeBossGoalRooms(levels);
// Place switches and the locks that require it:
placeSwitches();
// Make the dungeon less tree-like:
graphify();
computeIntensity(levels);
// Place the keys within the dungeon:
placeKeys(levels);
if (levels.keyCount()-1 != constraints.getMaxKeys())
throw new RetryException();
checkAcceptable();
return;
} catch (RetryException e) {
if (++ attempt > MAX_RETRIES) {
throw new GenerationFailureException("Dungeon generator failed", e);
}
//log("Retrying dungeon generation...");
}
}
}
@Override
public IRoomLayout getRoomLayout() {
return dungeon;
}
public boolean isBossRoomLocked() {
return bossRoomLocked;
}
public void setBossRoomLocked(boolean bossRoomLocked) {
this.bossRoomLocked = bossRoomLocked;
}
public boolean isGenerateGoal() {
return generateGoal;
}
public void setGenerateGoal(boolean generateGoal) {
this.generateGoal = generateGoal;
}
}