package com.rafaskoberg.gdx.typinglabel; import com.badlogic.gdx.graphics.Color; import com.badlogic.gdx.graphics.Colors; import com.badlogic.gdx.math.MathUtils; import com.badlogic.gdx.utils.Array; import com.badlogic.gdx.utils.reflect.ClassReflection; import com.badlogic.gdx.utils.reflect.Constructor; import com.badlogic.gdx.utils.reflect.ReflectionException; import regexodus.Matcher; import regexodus.Pattern; import regexodus.REFlags; /** Utility class to parse tokens from a {@link TypingLabel}. */ class Parser { private static final Pattern PATTERN_MARKUP_STRIP = Pattern.compile("(\\[{2})|(\\[#?\\w*(\\[|\\])?)"); private static final Pattern PATTERN_COLOR_HEX_NO_HASH = Pattern.compile("[A-F0-9]{6}"); private static final String[] BOOLEAN_TRUE = {"true", "yes", "t", "y", "on", "1"}; private static final int INDEX_TOKEN = 1; private static final int INDEX_PARAM = 2; private static Pattern PATTERN_TOKEN_STRIP; private static String RESET_REPLACEMENT; /** Parses all tokens from the given {@link TypingLabel}. */ static void parseTokens(TypingLabel label) { // Compile patterns if necessary if(PATTERN_TOKEN_STRIP == null || TypingConfig.dirtyEffectMaps) { PATTERN_TOKEN_STRIP = compileTokenPattern(); } if(RESET_REPLACEMENT == null || TypingConfig.dirtyEffectMaps) { RESET_REPLACEMENT = getResetReplacement(); } // Adjust and check markup color if(label.forceMarkupColor) label.getBitmapFontCache().getFont().getData().markupEnabled = true; // Remove any previous entries label.tokenEntries.clear(); // Parse all tokens with text replacements, namely color and var. parseReplacements(label); // Parse all regular tokens and properly register them parseRegularTokens(label); // Parse color markups and register SKIP tokens parseColorMarkups(label); // Sort token entries label.tokenEntries.sort(); label.tokenEntries.reverse(); } /** Parse tokens that only replace text, such as colors and variables. */ private static void parseReplacements(TypingLabel label) { // Get text CharSequence text = label.getText(); boolean hasMarkup = label.getBitmapFontCache().getFont().getData().markupEnabled; // Create string builder StringBuilder sb = new StringBuilder(text.length()); Matcher m = PATTERN_TOKEN_STRIP.matcher(text); int matcherIndexOffset = 0; // Iterate through matches while(true) { // Reset StringBuilder and matcher sb.setLength(0); m.setTarget(text); m.setPosition(matcherIndexOffset); // Make sure there's at least one regex match if(!m.find()) break; // Get token and parameter final InternalToken internalToken = InternalToken.fromName(m.group(INDEX_TOKEN)); final String param = m.group(INDEX_PARAM); // If token couldn't be parsed, move one index forward to continue the search if(internalToken == null) { matcherIndexOffset++; continue; } // Process tokens and handle replacement String replacement = ""; switch(internalToken) { case COLOR: if(hasMarkup) replacement = stringToColorMarkup(param); break; case ENDCOLOR: case CLEARCOLOR: if(hasMarkup) replacement = "[#" + label.getClearColor().toString() + "]"; break; case VAR: replacement = null; // Try to replace variable through listener. if(label.getTypingListener() != null) { replacement = label.getTypingListener().replaceVariable(param); } // If replacement is null, get value from maps. if(replacement == null) { replacement = label.getVariables().get(param.toUpperCase()); } // If replacement is still null, get value from global scope if(replacement == null) { replacement = TypingConfig.GLOBAL_VARS.get(param.toUpperCase()); } // Make sure we're not inserting "null" to the text. if(replacement == null) replacement = param.toUpperCase(); break; case RESET: replacement = RESET_REPLACEMENT + label.getDefaultToken(); break; default: // We don't want to process this token now. Move one index forward to continue the search matcherIndexOffset++; continue; } // Update text with replacement m.setPosition(m.start()); text = m.replaceFirst(replacement); } // Set new text label.setText(text, false, false); } /** Parses regular tokens that don't need replacement and register their indexes in the {@link TypingLabel}. */ private static void parseRegularTokens(TypingLabel label) { // Get text CharSequence text = label.getText(); // Create matcher and StringBuilder Matcher m = PATTERN_TOKEN_STRIP.matcher(text); StringBuilder sb = new StringBuilder(text.length()); int matcherIndexOffset = 0; // Iterate through matches while(true) { // Reset matcher and StringBuilder m.setTarget(text); sb.setLength(0); m.setPosition(matcherIndexOffset); // Make sure there's at least one regex match if(!m.find()) break; // Get token name and category String tokenName = m.group(INDEX_TOKEN).toUpperCase(); TokenCategory tokenCategory = null; InternalToken tmpToken = InternalToken.fromName(tokenName); if(tmpToken == null) { if(TypingConfig.EFFECT_START_TOKENS.containsKey(tokenName)) { tokenCategory = TokenCategory.EFFECT_START; } else if(TypingConfig.EFFECT_END_TOKENS.containsKey(tokenName)) { tokenCategory = TokenCategory.EFFECT_END; } } else { tokenCategory = tmpToken.category; } // Get token, param and index of where the token begins int groupCount = m.groupCount(); final String paramsString = groupCount == INDEX_PARAM ? m.group(INDEX_PARAM) : null; final String[] params = paramsString == null ? new String[0] : paramsString.split(";"); final String firstParam = params.length > 0 ? params[0] : null; final int index = m.start(0); int indexOffset = 0; // If token couldn't be parsed, move one index forward to continue the search if(tokenCategory == null) { matcherIndexOffset++; continue; } // Process tokens float floatValue = 0; String stringValue = null; Effect effect = null; switch(tokenCategory) { case WAIT: { floatValue = stringToFloat(firstParam, TypingConfig.DEFAULT_WAIT_VALUE); break; } case EVENT: { stringValue = paramsString; indexOffset = -1; break; } case SPEED: { switch(tokenName) { case "SPEED": float minModifier = TypingConfig.MIN_SPEED_MODIFIER; float maxModifier = TypingConfig.MAX_SPEED_MODIFIER; float modifier = MathUtils.clamp(stringToFloat(firstParam, 1), minModifier, maxModifier); floatValue = TypingConfig.DEFAULT_SPEED_PER_CHAR / modifier; break; case "SLOWER": floatValue = TypingConfig.DEFAULT_SPEED_PER_CHAR / 0.500f; break; case "SLOW": floatValue = TypingConfig.DEFAULT_SPEED_PER_CHAR / 0.667f; break; case "NORMAL": floatValue = TypingConfig.DEFAULT_SPEED_PER_CHAR; break; case "FAST": floatValue = TypingConfig.DEFAULT_SPEED_PER_CHAR / 2.000f; break; case "FASTER": floatValue = TypingConfig.DEFAULT_SPEED_PER_CHAR / 4.000f; break; } break; } case EFFECT_START: { Class<? extends Effect> clazz = TypingConfig.EFFECT_START_TOKENS.get(tokenName.toUpperCase()); try { if(clazz != null) { Constructor constructor = ClassReflection.getConstructors(clazz)[0]; int constructorParamCount = constructor.getParameterTypes().length; if(constructorParamCount >= 2) { effect = (Effect) constructor.newInstance(label, params); } else { effect = (Effect) constructor.newInstance(label); } } } catch(ReflectionException e) { String message = "Failed to initialize " + tokenName + " effect token. Make sure the associated class (" + clazz + ") has only one constructor with TypingLabel as first parameter and optionally String[] as second."; throw new IllegalStateException(message, e); } break; } case EFFECT_END: { break; } } // Register token TokenEntry entry = new TokenEntry(tokenName, tokenCategory, index + indexOffset, floatValue, stringValue); entry.effect = effect; label.tokenEntries.add(entry); // Set new text without tokens m.setPosition(0); text = m.replaceFirst(""); } // Update label text label.setText(text, false, false); } /** Parse color markup tags and register SKIP tokens. */ private static void parseColorMarkups(TypingLabel label) { // Get text final CharSequence text = label.getText(); // Iterate through matches and register skip tokens Matcher m = PATTERN_MARKUP_STRIP.matcher(text); while(m.find()) { final String tag = m.group(0); final int index = m.start(0); label.tokenEntries.add(new TokenEntry("SKIP", TokenCategory.SKIP, index, 0, tag)); } } /** Returns a float value parsed from the given String, or the default value if the string couldn't be parsed. */ static float stringToFloat(String str, float defaultValue) { if(str != null) { try { return Float.parseFloat(str); } catch(Exception e) { } } return defaultValue; } /** Returns a boolean value parsed from the given String, or the default value if the string couldn't be parsed. */ static boolean stringToBoolean(String str) { if(str != null) { for(String booleanTrue : BOOLEAN_TRUE) { if(booleanTrue.equalsIgnoreCase(str)) { return true; } } } return false; } /** Parses a color from the given string. Returns null if the color couldn't be parsed. */ static Color stringToColor(String str) { if(str != null) { // Try to parse named color Color namedColor = Colors.get(str.toUpperCase()); if(namedColor != null) { return new Color(namedColor); } // Try to parse hex if(str.length() >= 6) { try { return Color.valueOf(str); } catch(NumberFormatException ignored) { } } } return null; } /** Encloses the given string in brackets to work as a regular color markup tag. */ private static String stringToColorMarkup(String str) { if(str != null) { // Upper case str = str.toUpperCase(); // If color isn't registered by name, try to parse it as an hex code. Color namedColor = Colors.get(str); if(namedColor == null) { boolean isHexWithoutHashChar = str.length() >= 6 && PATTERN_COLOR_HEX_NO_HASH.matches(str); if(isHexWithoutHashChar) { str = "#" + str; } } } // Return color code return "[" + str + "]"; } /** * Returns a compiled {@link Pattern} that groups the token name in the first group and the params in an optional * second one. Case insensitive. */ private static Pattern compileTokenPattern() { StringBuilder sb = new StringBuilder(); sb.append("\\{("); Array<String> tokens = new Array<>(); TypingConfig.EFFECT_START_TOKENS.keys().toArray(tokens); TypingConfig.EFFECT_END_TOKENS.keys().toArray(tokens); for(InternalToken token : InternalToken.values()) { tokens.add(token.name); } for(int i = 0; i < tokens.size; i++) { sb.append(tokens.get(i)); if((i + 1) < tokens.size) sb.append('|'); } sb.append(")(?:=([;#-_ \\.\\w]+))?\\}"); return Pattern.compile(sb.toString(), REFlags.IGNORE_CASE); } /** Returns the replacement string intended to be used on {RESET} tokens. */ private static String getResetReplacement() { Array<String> tokens = new Array<>(); TypingConfig.EFFECT_END_TOKENS.keys().toArray(tokens); tokens.add("CLEARCOLOR"); tokens.add("NORMAL"); StringBuilder sb = new StringBuilder(); for(String token : tokens) { sb.append('{').append(token).append('}'); } return sb.toString(); } }