Java tutorial
/* * CrimsonGlow is an adult computer roleplaying game with spanking content. * Copyright (C) 2015 Andrew Russell * * 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 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.spankingrpgs.model; import com.fasterxml.jackson.annotation.JsonProperty; import com.spankingrpgs.model.characters.GameCharacter; import com.spankingrpgs.model.characters.Gender; import com.spankingrpgs.model.characters.SpankingRole; import com.spankingrpgs.model.combat.CombatRange; import com.spankingrpgs.model.music.MusicMap; import com.spankingrpgs.model.options.Options; import com.spankingrpgs.model.skills.Skill; import com.spankingrpgs.model.items.Equipment; import com.spankingrpgs.model.items.Item; import com.spankingrpgs.model.options.ArtificialIntelligenceLevel; import com.spankingrpgs.model.options.AttritionRate; import com.spankingrpgs.model.story.Event; import com.spankingrpgs.model.story.TextResolver; import com.spankingrpgs.util.CollectionUtils; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import javafx.scene.media.MediaPlayer; import java.io.IOException; import java.io.StringWriter; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; /** * Encapsulates the game's state. */ @JsonInclude(JsonInclude.Include.NON_NULL) public class GameState implements TextResolver { public static final String PC_NAME = "pc"; public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("MM/dd 'at' HH:mm"); private final static Logger LOG = Logger.getLogger(GameState.class.getCanonicalName()); private static GameState instance; private static GameState cleanInstance; private static String gameTitle; /** * Multivalued map of characters. Note that there should be at most two characters per name: an NPC and a PC. * All characters are saved. */ private final Map<String, GameCharacter> characters; /** * The player character. The player character is saved. */ private GameCharacter playerCharacter; /** * The party is saved. */ private final Map<CombatRange, List<String>> party; /** * A set of keywords. Keywords are used to track decisions made by the player, allowing us to write events that * change depending on past decisions. * These are saved. */ private final Set<String> keywords; /** * Since items cannot be modified, they are not saved. */ private Map<String, Item> items; private Map<String, Equipment> equipment; /** * The current event (if any) is saved */ private Event currentEvent; /** * The text displayed as a part of `currentEvent`. This may not be the exact same as the text stored in * `currentEvent`. It may be that the currentEvent has changes that would change the rendered text if the event * were played again, or it may be that the game engine runs through all automatic choices at once, and displays * them as one event. Each entry is a paragraph of text. */ private List<String> currentEventText; /** * Since the events cannot be modified by the player, they are not saved */ private Map<String, Event> events; private int numTimesLost; private ArtificialIntelligenceLevel artificialIntelligenceLevel; private AttritionRate attritionRate; private boolean playerSpankable; private Calendar gameTime; private Map<String, Skill> skills; private int episodeNumber; private int dayNumber; private Gender spankerGender; private Gender spankeeGender; private String musicName; /** * The number of hours that a given activity takes */ private int activityLength; private Map<String, GameCharacter> previousVillains; private GameState(GameCharacter playerCharacter) { this.playerCharacter = playerCharacter; characters = new LinkedHashMap<>(); keywords = new HashSet<>(); items = new HashMap<>(); events = new HashMap<>(); party = new HashMap<>(CombatRange.values().length); Arrays.stream(CombatRange.values()).forEach(range -> this.party.put(range, new ArrayList<>(6))); attritionRate = AttritionRate.MODERATE; artificialIntelligenceLevel = ArtificialIntelligenceLevel.AVERAGE; playerSpankable = true; numTimesLost = 0; skills = new HashMap<>(); equipment = new HashMap<>(); episodeNumber = 1; dayNumber = 1; activityLength = 4; gameTime = Calendar.getInstance(); this.previousVillains = new HashMap<>(); try { gameTime.setTime(DATE_FORMAT.parse("09/01 at 11:00")); } catch (ParseException e) { String msg = String.format("Encountered an error formatting the date when creating the state: %s", e); LOG.log(Level.SEVERE, msg); throw new RuntimeException(msg, e); } if (playerCharacter != null) { characters.put(PC_NAME, playerCharacter); } } /** * Performs a deep copy of the passed in GameState. * * @param copy The state to perform a deep copy of */ private GameState(GameState copy) { this.playerCharacter = copy.playerCharacter.copy(); characters = new LinkedHashMap<>( copy.characters.entrySet().stream().filter(entry -> entry.getValue() != null) .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().copy()))); keywords = new HashSet<>(copy.keywords); items = new HashMap<>(copy.items); events = new HashMap<>(copy.events); party = new HashMap<>(copy.party.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, rangeCharacters -> rangeCharacters.getValue().stream().collect(Collectors.toList())))); attritionRate = copy.attritionRate; artificialIntelligenceLevel = copy.artificialIntelligenceLevel; playerSpankable = copy.playerSpankable; numTimesLost = copy.numTimesLost; skills = new HashMap<>(copy.skills); equipment = new HashMap<>(copy.equipment); episodeNumber = copy.episodeNumber; dayNumber = copy.dayNumber; activityLength = copy.activityLength; gameTime = copy.gameTime; this.previousVillains = copy.previousVillains.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> characters.get(entry.getValue().getName()))); this.spankerGender = copy.spankerGender; this.spankeeGender = copy.spankeeGender; } public static void setGameTitle(String title) { gameTitle = title; } public static String getGameTitle() { return gameTitle; } /** * Initializes the game state. Useful when creating unique GameStates for tests * * @param playerCharacter The player character. * * @throws IllegalStateException if the state has already been initialized */ public static void create(GameCharacter playerCharacter) { cleanInstance = new GameState(playerCharacter); } /** * Resets the game state to a fresh copy. */ public static void clear() { if (instance == null) { return; } instance = new GameState(cleanInstance); } /** * Destroys the state. */ public static void destroy() { instance = null; } /** * Get the game state. * * @return The game state. * * @throws IllegalStateException If the game state has not yet been initialized. */ @JsonIgnore public static GameState getInstance() { if (instance == null) { if (cleanInstance == null) { cleanInstance = new GameState((GameCharacter) null); } instance = new GameState(cleanInstance); } return instance; } @JsonIgnore public static GameState getCleanInstance() { return cleanInstance; } /** * Adds the specified NPC to the state. * * @param character The character to be added. * * @throws IllegalArgumentException If there is already a nonplayer character with the specified name, or the * nonplayer character's name is {@link GameState#PC_NAME}. */ public void addNonPlayerCharacter(GameCharacter character) { String normalizedName = character.getName().toLowerCase(); if (normalizedName.equals(PC_NAME)) { String msg = String.format("Tried to add a non-player character with illegal name: %s", PC_NAME); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } if (characters.containsKey(normalizedName)) { String msg = String.format("A character already exists with name %s: %s", character.getName(), characters.get(character.getName())); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } characters.put(normalizedName, character); } /** * Overwrites the character with the same name as the passed in character with this character. * * @param character The character to overwrite with */ public void overwriteCharacter(GameCharacter character) { characters.put(character.getName().toLowerCase(), character); if (character.getName().toLowerCase().equals(PC_NAME)) { setPlayerCharacter(character); } } /** * Adds the specified characters to the state. * * @param characters The characters to be added to the state * * @throws IllegalArgumentException If there is already a character with the same name as one of the characters, * or at least two of the characters have the same name, or one of them has name {@link GameState#PC_NAME}. */ @SuppressWarnings("unchecked") public void addNonPlayerCharacters(GameCharacter... characters) { Arrays.stream(characters).forEach(this::addNonPlayerCharacter); } /** * Returns the desired non player character. * * @param characterName The name of the desired character * * @return The desired non-player character * * @throws IllegalArgumentException If there is no NPC with the specified name */ @JsonIgnore public GameCharacter getNonPlayerCharacter(String characterName) { GameCharacter character = characters.get(characterName.toLowerCase()); if (character == null) { String msg = String.format("There is no character with name: %s", characterName); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } return character; } public Gender getSpankerGender() { return spankerGender; } public Gender getSpankeeGender() { return spankeeGender; } @JsonIgnore @Override public GameCharacter getCharacter(String name) { return name.toLowerCase().equals(PC_NAME) ? playerCharacter : getNonPlayerCharacter(name.toLowerCase()); } /** * Adds the specified character to the state. * Note that if a character with the same system name ({@link GameCharacter#getName()}) already exists, that * character will be overwritten. * * @param newCharacter The new character to put in the state */ public void setCharacter(GameCharacter newCharacter) { characters.put(newCharacter.getName(), newCharacter); } /** * Returns the character with the specified name from the set of enemies that the player fought last, with the * gender of the spankee. * <p> * * @param name The system name of the character we are interested in * * @return The character of interest * * @throws IllegalArgumentException if there is no character with name {@code name}. */ @JsonIgnore public GameCharacter getVillainSpankee(String name) { /* * This is very much a quick and dirty hack to get the first episode working correctly. It assumes that * all the enemies have the same name, and there is guaranteed to be at least one enemy with the spankee gender, * so it doesn't matter which one is picked. We need to get much smarter if we want to have enemies of different * types in a situation where the player can spank them, and we need a way of saying that we want the spanker * instead of the spankee. */ GameCharacter villain = CollectionUtils.getValue(previousVillains, name.toLowerCase()); if (getSpankeeGender() != Gender.UNKNOWN) { villain.setGender(getSpankeeGender()); } return CollectionUtils.getValue(previousVillains, name.toLowerCase()); } /** * Removes all non-player characters from this state */ public void clearNonPlayerCharacters() { characters.clear(); characters.put(PC_NAME.toLowerCase(), playerCharacter); } /** * Sets the specified character as the player character * * @param playerCharacter The character to set as the player character * * @throws IllegalArgumentException if the character's name is not PC_NAME. */ public void setPlayerCharacter(GameCharacter playerCharacter) { if (!playerCharacter.getName().equals(PC_NAME)) { String msg = String.format("Tried to set an NPC as the player character: %s", playerCharacter); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } this.playerCharacter = playerCharacter; characters.put(PC_NAME, playerCharacter); } public GameCharacter getPlayerCharacter() { return playerCharacter; } /** * Adds the specified character to the party at the specified starting combat range. * * @param range The range at which the character should begin combat * @param characterName The name of the character to add */ public void joinParty(CombatRange range, String characterName) { if (inParty(characterName)) { leaveParty(characterName); } party.get(range).add(characterName.toLowerCase()); LOG.info(String.format("New party: %s", party)); } public void leaveParty(String characterName) { party.keySet().forEach(range -> party.get(range).remove(characterName)); } /** * Determines if the specified character is in the active party. * * @param characterName The name of the character who may or may not be in the active party * * @return True if the specified character is in the party, false otherwise */ public boolean inParty(String characterName) { return party.values().stream().flatMap(List::stream).anyMatch(characterName.toLowerCase()::equals); } /** * Returns an unmodifiable version of the active party * * @return An unmodifiable copy of the active party */ @JsonIgnore public Map<CombatRange, List<GameCharacter>> getParty() { return party.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, rangeCharacters -> rangeCharacters.getValue().stream() .map(this::getCharacter).collect(Collectors.toList()))); } /** * Returns a mapping from combat range to a list of character names, which is then serialized by Jackson to save the * current party. * * @return A mapping from CombatRange to a list of character names in the party at that range */ @JsonProperty("party") public Map<CombatRange, List<String>> getSerializedParty() { return Collections.unmodifiableMap(getParty().entrySet().stream().collect(Collectors.toMap( Map.Entry::getKey, entry -> entry.getValue().stream().map(GameCharacter::getName).collect(Collectors.toList())))); } public void setParty(Map<CombatRange, List<GameCharacter>> party) { this.party.values().forEach(List::clear); party.keySet() .forEach(range -> party.get(range).forEach(character -> joinParty(range, character.getName()))); } public void addKeyword(String keyword) { keywords.add(keyword.toLowerCase()); } public void removeKeyword(String keyword) { keywords.remove(keyword.toLowerCase()); } public boolean hasKeyword(String keyword) { return keywords.contains(keyword.toLowerCase()); } public void addItem(String name, Item item) { if (item instanceof Equipment) { equipment.put(name.toLowerCase(), (Equipment) item); } items.put(name.toLowerCase(), item); } public void removeItem(String name) { items.remove(name.toLowerCase()); } @JsonIgnore public Item getItem(String name) { return CollectionUtils.getValue(items, name); } @JsonIgnore public Equipment getEquipment(String name) { return CollectionUtils.getValue(equipment, name.toLowerCase()); } public void addSkill(String name, Skill skill) { skills.put(name.toLowerCase(), skill); } @JsonIgnore public Skill getSkill(String name) { return CollectionUtils.getValue(skills, name.toLowerCase()); } @JsonIgnore public Map<String, Skill> getSkills() { return skills; } @JsonIgnore public Event getCurrentEvent() { return currentEvent; } /** * Returns the name of the current event. * Used by the Jackson deserializer. * * @return The name of the current event */ public String getCurrentEventName() { return events.entrySet().stream().filter(entry -> entry.getValue().equals(getCurrentEvent())) .map(Map.Entry::getKey).findFirst().get(); } public void setCurrentEvent(Event currentEvent) { this.currentEvent = currentEvent; } public void setCurrentEventText(List<String> eventText) { this.currentEventText = eventText; } public List<String> getCurrentEventText() { return currentEventText == null ? Collections.singletonList(currentEvent.play(this)) : currentEventText; } /** * Set the current event to the event specified by the passed in name. * Used by the Jackson deserializer * * @param eventName The name of the event to set as the current event */ public void setCurrentEvent(String eventName) { currentEvent = events.get(eventName); } public void addEvent(String name, Event event) { events.put(name.toLowerCase(), event); } @JsonIgnore public Event getEvent(String name) { return events.get(name.toLowerCase()); } /** * Returns an unmodifiable version of the game's characters. * * @return An unmodifiable version of the mapping of characters */ @JsonIgnore public Map<String, GameCharacter> getCharacters() { return Collections.unmodifiableMap(characters); } public Map<String, Gender> getCharacterGender() { return characters.entrySet().stream() .filter(nameCharacter -> nameCharacter.getValue() != getPlayerCharacter()).collect( Collectors.toMap(Map.Entry::getKey, nameCharacter -> nameCharacter.getValue().getGender())); } public void loadCharacterGender(JsonNode node) { Iterator<String> characterNamesGenders = node.fieldNames(); while (characterNamesGenders.hasNext()) { String name = characterNamesGenders.next(); getCharacter(name).setGender(Gender.valueOf(node.get(name).asText())); } } /** * Returns an unmodifiable collection of the keywords accumulated by the player. * * @return An unmodifiable collection of the keywords accumulated by the player */ public Set<String> getKeywords() { return Collections.unmodifiableSet(keywords); } public int getNumTimesLost() { return numTimesLost; } public int getEpisodeNumber() { return episodeNumber; } public int getDayNumber() { return dayNumber; } public int getActivityLength() { return activityLength; } public void setActivityLength(int activityLength) { this.activityLength = activityLength; } public Map<String, GameCharacter> getPreviousVillains() { return previousVillains; } public void setPreviousVillains(Map<String, GameCharacter> previousVillains) { this.previousVillains = previousVillains; } /** * Returns a JSON string containing the data that needs to be saved across play sessions. * * @return A JSON string containing all the data that needs to be saved across play sessions. */ public String save() { StringWriter writer = new StringWriter(); try { new ObjectMapper().writeValue(writer, this); } catch (IOException e) { throw new RuntimeException(e); } return writer.toString(); } /** * Loads the data encoded in the passed in JSON into this state. * * @param stateToLoad The JSON string to load into this state */ @SuppressWarnings("unchecked") public void load(String stateToLoad) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); JsonNode rootNode; try { rootNode = mapper.readValue(stateToLoad, JsonNode.class); } catch (IOException e) { throw new RuntimeException(e); } loadPlayerCharacter(rootNode.get("playerCharacter")); loadKeywords(rootNode.get("keywords")); setNumTimesLost(rootNode.get("numTimesLost").asInt()); setEpisodeNumber(rootNode.get("episodeNumber").asInt()); setDayNumber(rootNode.get("dayNumber").asInt()); setActivityLength(rootNode.get("activityLength").asInt()); setAttritionRate(AttritionRate.valueOf(rootNode.get("attritionRate").asText())); setArtificialIntelligenceLevel( ArtificialIntelligenceLevel.valueOf(rootNode.get("artificialIntelligenceLevel").asText())); setPlayerSpankable(rootNode.get("playerSpankable").asBoolean()); setSpankerGender(Gender.valueOf(rootNode.get("spankerGender").asText())); setSpankeeGender(Gender.valueOf(rootNode.get("spankeeGender").asText())); setMusic(rootNode.get("music") == null ? null : rootNode.get("music").asText()); Options.setGenderForRole(SpankingRole.SPANKER, getSpankerGender()); Options.setGenderForRole(SpankingRole.SPANKEE, getSpankeeGender()); loadPreviousVillains(rootNode.get("previousVillains")); loadCurrentEvent(rootNode.get("currentEventName")); loadParty(rootNode.get("party")); loadCharacterGender(rootNode.get("characterGender")); try { setCurrentEventText((List<String>) mapper.treeToValue(rootNode.get("currentEventText"), List.class)); setGameTime(mapper.treeToValue(rootNode.get("gameTime"), Calendar.class)); } catch (JsonProcessingException e) { String msg = String.format("Experienced an error while loading node %s: %s", rootNode.get("gameTime").toString(), e); LOG.log(Level.SEVERE, msg); throw new RuntimeException(msg, e); } } private void loadParty(JsonNode party) { Iterator<String> combatRanges = party.fieldNames(); Map<CombatRange, List<GameCharacter>> partyMap = new LinkedHashMap<>(); while (combatRanges.hasNext()) { CombatRange range = CombatRange.valueOf(combatRanges.next()); List<GameCharacter> charactersAtRange = new LinkedList<>(); JsonNode rangeList = party.get(range.name()); for (int i = 0; i < rangeList.size(); i++) { charactersAtRange.add(getCharacter(rangeList.get(i).asText())); } partyMap.put(range, charactersAtRange); } setParty(partyMap); } private void loadPreviousVillains(JsonNode previousVillainsNode) { Iterator<String> villainNames = previousVillainsNode.fieldNames(); while (villainNames.hasNext()) { String villainName = villainNames.next(); previousVillains.put(villainName, characters.get(villainName).copy()); previousVillains.get(villainName).load(previousVillainsNode.get(villainName)); } } private void loadPlayerCharacter(JsonNode playerCharacterNode) { CollectionUtils.getValue(characters, PC_NAME).load(playerCharacterNode); setPlayerCharacter(characters.get(PC_NAME)); } private void loadKeywords(JsonNode keywords) { Iterator<JsonNode> keywordsIterator = keywords.elements(); this.keywords.clear(); while (keywordsIterator.hasNext()) { this.keywords.add(keywordsIterator.next().asText().trim()); } } private void loadCurrentEvent(JsonNode currentEventName) { this.currentEvent = events.get(currentEventName.asText()); } public void setDayNumber(int dayNumber) { this.dayNumber = dayNumber; } public void setEpisodeNumber(int episodeNumber) { this.episodeNumber = episodeNumber; } public void setGameTime(Calendar gameTime) { this.gameTime = gameTime; } public Calendar getGameTime() { return gameTime; } public void addHours(int numHours) { gameTime.add(Calendar.HOUR_OF_DAY, numHours); } public void addMinutes(int numMinutes) { gameTime.add(Calendar.MINUTE, numMinutes); } public void setNumTimesLost(int numTimesLost) { this.numTimesLost = numTimesLost; } public void setSpankerGender(Gender gender) { this.spankerGender = gender; } public void setSpankeeGender(Gender gender) { this.spankeeGender = gender; } /** * Increments the number of times lost by 1. */ public void incrementNumTimesLost() { numTimesLost++; } public AttritionRate getAttritionRate() { return attritionRate; } public void setAttritionRate(AttritionRate attritionRate) { this.attritionRate = attritionRate; } public ArtificialIntelligenceLevel getArtificialIntelligenceLevel() { return artificialIntelligenceLevel; } public void setArtificialIntelligenceLevel(ArtificialIntelligenceLevel artificialIntelligenceLevel) { this.artificialIntelligenceLevel = artificialIntelligenceLevel; } public boolean isPlayerSpankable() { return playerSpankable; } public void setPlayerSpankable(boolean playerSpankable) { this.playerSpankable = playerSpankable; } public void addPlayerCharacter(GameCharacter playerCharacter) { this.playerCharacter = playerCharacter; characters.put(PC_NAME, playerCharacter); } /** * Given a list of characters, returns a new list with all the player defined characters filtered out. * * @param gameCharacters The characters to be filtered * @return A new list of characters with all player characters filtered out */ private List<GameCharacter> filterPlayerCharacters(List<GameCharacter> gameCharacters) { return gameCharacters.stream().filter(character -> !character.equals(playerCharacter)) .collect(Collectors.toList()); } /** * Stop playing the current music. * If there is no music playing, this method does nothing */ public void stopMusic() { if (MusicMap.mediaPlayer != null) { MusicMap.mediaPlayer.stop(); } } /** * Start playing the music with the specified name. * If the music to start playing is the same as the currently played music, this method does nothing. * * @param music The name of the music to play */ public void startMusic(String music) { startMusic(music, false); } /** * Start playing the music with the specified name. * * @param music The name of the music to play * @param restart If true, the music specified will be played from the beginning even if it is already playing, * if false then the music will not be restarted if already playing */ public void startMusic(String music, boolean restart) { if (musicName != null && !restart && musicName.equals(music)) { return; } stopMusic(); musicName = music; MusicMap musicMap = MusicMap.INSTANCE; if (musicMap.get(musicName) == null) { LOG.warning(String.format("No music for %s found. MusicMap: %s", music, musicMap)); return; } MusicMap.mediaPlayer = new MediaPlayer(MusicMap.INSTANCE.get(music)); MusicMap.mediaPlayer.setCycleCount(MediaPlayer.INDEFINITE); MusicMap.mediaPlayer.play(); } public String getMusic() { return musicName; } public void setMusic(String musicName) { this.musicName = musicName; } }