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.scarletmoon.loader; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.spankingrpgs.model.GameState; import com.spankingrpgs.model.loader.Loader; import com.spankingrpgs.model.music.MusicMap; import com.spankingrpgs.model.story.Event; import com.spankingrpgs.model.story.EventDescription; import com.spankingrpgs.model.story.EventFactory; import com.spankingrpgs.model.story.TextParser; import com.spankingrpgs.model.story.TextResolver; import com.spankingrpgs.model.story.UniversalEvent; import com.spankingrpgs.util.CollectionUtils; import javafx.scene.media.Media; import java.io.File; import java.io.IOException; import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * Responsible for loading events. Events are the story events in the game. The JSON format is: * * { * "name": "event", * "text" : "Imma do lots of things.", * "do": ["addKeyword(keyword)", "decrementStatistic(strength, 2)"] * "automatic choices": { * "hasKeyword(keyword)": "otherEvent", * "isFemale()": "femaleEvent", * }, * "choices": { * "Punch [himher(alexandra)] in the face": punchEvent", * "Kiss [himher(alexandra)[ on the lips": kissEvent" * } * } * * and in YAML: * *- name: event * text : | * Imma do lots of things. * do: [addKeyword(keyword), decrementStatistic(strength, 2)] * automatic choices: * hasKeyword(keyword): otherEvent, * isFemale(): femaleEvent, * choices: * Punch [himher(alexandra)] in the face": punchEvent * Kiss [himher(alexandra)[ on the lips": kissEvent */ public class EventLoader implements Loader { private static final Logger LOG = Logger.getLogger(EventLoader.class.getName()); private static final ObjectMapper JSON_PARSER = new ObjectMapper(); private static final Map<String, Function<List<String>, Predicate<GameState>>> predicateBuilders = new HashMap<>(); private static Map<String, Function<List<String>, Consumer<TextResolver>>> stateCommands = new HashMap<>(); private final EventFactory eventFactory; private final ObjectMapper parser; private final String gameRoot; /** * This should be true in actual application. This exists solely so that I can turn off loading music for * tests, since JavaFX doesn't let me build a MediaPlayer without starting the application, which I can't do through * a unit test. */ private static boolean loadMusic = true; /** * Registers the specified Predicate builder under the specified name. * * @param name The name of the predicate * * @param builder A function that maps a list of arguments to the predicate (as Strings) to a Predicate on a * GameState */ public static void registerPredicate(String name, Function<List<String>, Predicate<GameState>> builder) { predicateBuilders.put(name, builder); } /** * Registers the specified state modifying command builder to the specified name. * * @param name The name to register the dynamic text function builder under * @param builder The state modifying command builder to be registered */ public static void registerCommandBuilder(String name, Function<List<String>, Consumer<TextResolver>> builder) { stateCommands.put(name, builder); } /** * Builds an object capable of parsing a file format parseable by Jackson into a GameEvent. * * @param eventFactory The object that builds events * @param parser The parser to use to parse character files * @param gameRoot The root directory of the game */ public EventLoader(EventFactory eventFactory, ObjectMapper parser, String gameRoot) { this.eventFactory = eventFactory; this.parser = parser; this.gameRoot = gameRoot; } /** * Builds an object capable of parsing a file format parseable by Jackson into a GameEvent. * Defaults the {@code eventFactory} to {@code UniversalEvent::new} * * @param parser The parser to use to hydrate event files into game events * @param gameRoot The root directory of the game */ public EventLoader(ObjectMapper parser, String gameRoot) { this(UniversalEvent::new, parser, gameRoot); } /** * Builds an object capable of parsing JSON files into Events. * Defaults the {@code eventFactory} to {@code UniversalEvent::new} and the {@code parser} to a standard * Jackson ObjectMapper that allows comments. * * @param gameRoot The root of the game */ public EventLoader(String gameRoot) { this(UniversalEvent::new, JSON_PARSER, gameRoot); JSON_PARSER.configure(JsonParser.Feature.ALLOW_COMMENTS, true); } @Override public void load(Collection<String> eventData, GameState state) { eventData.stream().forEach(datum -> loadEvent(datum, state)); } /** * Parses the passed JSON into an Event, and loads it into the specified state. * * @param eventData The JSON representing the Event * @param state The state to load the event into */ private void loadEvent(String eventData, GameState state) { try { List<JsonNode> eventList = parser.readValue(eventData, new TypeReference<List<JsonNode>>() { }); for (JsonNode eventJson : eventList) { verifyFields(eventJson); String name = eventJson.get("name").asText(); JsonNode eventBody = eventJson.get("text"); String newLine = parser == JSON_PARSER ? TextParser.NEW_LINE_MARKER : "\n"; Event event = eventFactory.build( eventBody == null ? EventDescription.EMPTY_BODY : TextParser.parse(eventBody.asText().trim().replace("''", "'"), newLine), hydrateCommands(eventJson.get("do")), hydrateAutomatedChoices(eventJson.get("automatic choices")), hydrateChoices(eventJson.get("choices")), loadMusic ? hydrateMusic(eventJson.get("music")) : null); state.addEvent(name, event); } } catch (IOException e) { LOG.log(Level.SEVERE, e.getMessage()); throw new IllegalArgumentException(e); } } /** * Constructs a media object from a music file name, and stores it in the {@link MusicMap}. * * @param music The music file to turn into media * * @return The name of the song that was just stored */ private String hydrateMusic(JsonNode music) { if (music == null) { return null; } String musicName = music.asText(); MusicMap musicMap = MusicMap.INSTANCE; if (musicMap.containsKey(musicName)) { return musicName; } //ogg is not supported List<String> types = Arrays.asList(".mp3", ".wav"); Optional<String> musicFileName = types.stream() .map(type -> Paths.get(gameRoot, "data", "music", music.asText() + type)).map(Path::toString) .map(File::new).filter(File::exists).map(File::toURI).map(URI::toString).findFirst(); if (!musicFileName.isPresent()) { LOG.warning(String.format("Music %s not found.", musicFileName)); return musicName; } Media media = new Media(musicFileName.get()); musicMap.put(musicName, media); return musicName; } /** * Given a node containing state modifying commands that need to be executed, constructs a Consumer * that applies all of those changes in sequence. * * @param commands The commands to execute at the end of the event, if null then returns * {@link UniversalEvent#NO_CHANGES} * * @return The consumer that executes all of the commands when given a {@link GameState} */ private Consumer<TextResolver> hydrateCommands(JsonNode commands) { if (commands == null) { return UniversalEvent.NO_CHANGES; } List<Consumer<TextResolver>> hydratedCommands = new ArrayList<>(); for (JsonNode command : commands) { Matcher matcher = Pattern.compile(TextParser.FUNCTION_REGEX).matcher(command.asText()); if (!matcher.matches()) { String msg = String.format("%s is not a valid function expression.", command.asText()); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } String functionName = matcher.group(TextParser.FUNCTION_NAME_GROUP); List<String> functionArgs = Arrays.stream(matcher.group(TextParser.FUNCTION_ARGUMENTS_GROUP).split(",")) .map(String::trim).collect(Collectors.toList()); hydratedCommands.add(CollectionUtils.getValue(stateCommands, functionName).apply(functionArgs)); } return gameState -> hydratedCommands.stream().forEach(command -> command.accept(gameState)); } /** * Hydrates the character's choices. These are choices the player makes explicitly to move through the Event * tree. * * @param choices The JSON Object containing the available choices * * @return A mapping from the text displayed to the player to the names of the Events that describe the consequences * of making a particular choice */ private LinkedHashMap<EventDescription, String> hydrateChoices(JsonNode choices) { if (choices == null) { return new LinkedHashMap<>(); } Iterator<String> choicesText = choices.fieldNames(); LinkedHashMap<EventDescription, String> hydratedChoices = new LinkedHashMap<>(); String newLine = parser == JSON_PARSER ? TextParser.NEW_LINE_MARKER : "\n"; while (choicesText.hasNext()) { String playerChoiceText = choicesText.next(); hydratedChoices.put(TextParser.parse(playerChoiceText, newLine), choices.get(playerChoiceText).asText()); } return hydratedChoices; } private LinkedHashMap<Predicate<GameState>, String> hydrateAutomatedChoices(JsonNode automaticChoices) { if (automaticChoices == null) { return new LinkedHashMap<>(); } Iterator<String> predicateStrings = automaticChoices.fieldNames(); LinkedHashMap<Predicate<GameState>, String> hydratedAutomatedChoices = new LinkedHashMap<>(); while (predicateStrings.hasNext()) { String predicateString = predicateStrings.next(); Predicate<GameState> predicate = parsePredicateString(predicateString); hydratedAutomatedChoices.put(predicate, automaticChoices.get(predicateString).asText()); } return hydratedAutomatedChoices; } /** * Given a string representation of a predicate, returns the actual predicate. This method assumes that all the * predicates are prefix, and not nested. * * @param predicate The function string to be hydrated * * @return The appropriate predicate that takes a GameState * * @throws IllegalArgumentException if {@code function} is not the name of a dynamic text function, or the * function is malformed. */ private Predicate<GameState> parsePredicateString(String predicate) { predicate = predicate.trim(); if (predicate.contains("&&")) { return Arrays.stream(predicate.split("&&")).map(this::parsePredicateString).reduce(Predicate::and) .get(); } if (predicate.toLowerCase().equals("true")) { return ignored -> true; } Matcher prefixFunctionMatcher = Pattern.compile(TextParser.FUNCTION_REGEX).matcher(predicate); if (!prefixFunctionMatcher.matches()) { String msg = String.format("Malformed predicate: %s", predicate); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } String predicateName = prefixFunctionMatcher.group(TextParser.FUNCTION_NAME_GROUP); String predicateArguments = prefixFunctionMatcher.group(TextParser.FUNCTION_ARGUMENTS_GROUP); Function<List<String>, Predicate<GameState>> predicateBuilder = predicateBuilders.get(predicateName); if (predicateBuilder == null) { String msg = String.format("No predicate with the name %s exists.", predicateName); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } return predicateBuilder .apply(Arrays.stream(predicateArguments.split(",")).map(String::trim).collect(Collectors.toList())); } private void verifyFields(JsonNode eventData) { List<String> missingFields = new ArrayList<>(); if (eventData.get("name") == null) { missingFields.add("name"); } if (!missingFields.isEmpty()) { String msg = String.format("Character %s is missing fields:\n%s", eventData, String.join("\n", missingFields)); LOG.log(Level.SEVERE, msg); throw new IllegalArgumentException(msg); } } }