|
| 1 | +package com.jvn.core.vn.text; |
| 2 | + |
| 3 | +import java.util.ArrayList; |
| 4 | +import java.util.List; |
| 5 | +import java.util.regex.Matcher; |
| 6 | +import java.util.regex.Pattern; |
| 7 | + |
| 8 | +/** |
| 9 | + * Parses inline markup in dialogue text and converts to TextSpan list. |
| 10 | + * |
| 11 | + * Markup format: |
| 12 | + * - {shake}text{/shake} - Shaking text |
| 13 | + * - {wave}text{/wave} - Wave effect |
| 14 | + * - {bounce}text{/bounce} - Bouncing text |
| 15 | + * - {color=#FF0000}text{/color} - Colored text |
| 16 | + * - {speed=0.5}text{/speed} - Slower text (0.5x) |
| 17 | + * - {speed=2.0}text{/speed} - Faster text (2x) |
| 18 | + * - {delay=500} - 500ms pause |
| 19 | + * - {b}text{/b} - Bold |
| 20 | + * - {i}text{/i} - Italic |
| 21 | + * - {rainbow}text{/rainbow} - Rainbow colors |
| 22 | + * |
| 23 | + * Effects can be nested: {shake}{color=#FF0000}scary{/color}{/shake} |
| 24 | + */ |
| 25 | +public class TextParser { |
| 26 | + |
| 27 | + // Pattern to match tags like {shake}, {/shake}, {color=#FF0000}, {delay=500} |
| 28 | + private static final Pattern TAG_PATTERN = Pattern.compile("\\{(/?)([a-zA-Z]+)(?:=([^}]+))?\\}"); |
| 29 | + |
| 30 | + /** |
| 31 | + * Parse text with inline markup into a list of TextSpans |
| 32 | + */ |
| 33 | + public static List<TextSpan> parse(String text) { |
| 34 | + if (text == null || text.isEmpty()) { |
| 35 | + return List.of(new TextSpan("")); |
| 36 | + } |
| 37 | + |
| 38 | + List<TextSpan> spans = new ArrayList<>(); |
| 39 | + |
| 40 | + // Track current state |
| 41 | + TextEffect currentEffect = TextEffect.NONE; |
| 42 | + String currentColor = null; |
| 43 | + float currentSpeed = 1.0f; |
| 44 | + int pendingDelay = 0; |
| 45 | + |
| 46 | + Matcher matcher = TAG_PATTERN.matcher(text); |
| 47 | + int lastEnd = 0; |
| 48 | + |
| 49 | + while (matcher.find()) { |
| 50 | + // Add text before this tag |
| 51 | + if (matcher.start() > lastEnd) { |
| 52 | + String segment = text.substring(lastEnd, matcher.start()); |
| 53 | + if (!segment.isEmpty()) { |
| 54 | + spans.add(new TextSpan(segment, currentEffect, currentColor, currentSpeed, pendingDelay)); |
| 55 | + pendingDelay = 0; // Clear delay after use |
| 56 | + } |
| 57 | + } |
| 58 | + |
| 59 | + String isClosing = matcher.group(1); |
| 60 | + String tagName = matcher.group(2).toLowerCase(); |
| 61 | + String tagValue = matcher.group(3); |
| 62 | + |
| 63 | + if (isClosing.isEmpty()) { |
| 64 | + // Opening tag |
| 65 | + switch (tagName) { |
| 66 | + case "shake" -> currentEffect = TextEffect.SHAKE; |
| 67 | + case "wave" -> currentEffect = TextEffect.WAVE; |
| 68 | + case "bounce" -> currentEffect = TextEffect.BOUNCE; |
| 69 | + case "rainbow" -> currentEffect = TextEffect.RAINBOW; |
| 70 | + case "b", "bold" -> currentEffect = TextEffect.BOLD; |
| 71 | + case "i", "italic" -> currentEffect = TextEffect.ITALIC; |
| 72 | + case "color" -> { |
| 73 | + if (tagValue != null) currentColor = tagValue; |
| 74 | + } |
| 75 | + case "speed" -> { |
| 76 | + if (tagValue != null) { |
| 77 | + try { currentSpeed = Float.parseFloat(tagValue); } |
| 78 | + catch (NumberFormatException ignored) {} |
| 79 | + } |
| 80 | + } |
| 81 | + case "delay" -> { |
| 82 | + if (tagValue != null) { |
| 83 | + try { pendingDelay = Integer.parseInt(tagValue); } |
| 84 | + catch (NumberFormatException ignored) {} |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + } else { |
| 89 | + // Closing tag |
| 90 | + switch (tagName) { |
| 91 | + case "shake", "wave", "bounce", "rainbow", "b", "bold", "i", "italic" -> currentEffect = TextEffect.NONE; |
| 92 | + case "color" -> currentColor = null; |
| 93 | + case "speed" -> currentSpeed = 1.0f; |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + lastEnd = matcher.end(); |
| 98 | + } |
| 99 | + |
| 100 | + // Add remaining text |
| 101 | + if (lastEnd < text.length()) { |
| 102 | + String segment = text.substring(lastEnd); |
| 103 | + if (!segment.isEmpty()) { |
| 104 | + spans.add(new TextSpan(segment, currentEffect, currentColor, currentSpeed, pendingDelay)); |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + // Ensure at least one span |
| 109 | + if (spans.isEmpty()) { |
| 110 | + spans.add(new TextSpan("")); |
| 111 | + } |
| 112 | + |
| 113 | + return spans; |
| 114 | + } |
| 115 | + |
| 116 | + /** |
| 117 | + * Get plain text without markup tags |
| 118 | + */ |
| 119 | + public static String stripTags(String text) { |
| 120 | + if (text == null) return ""; |
| 121 | + return TAG_PATTERN.matcher(text).replaceAll(""); |
| 122 | + } |
| 123 | + |
| 124 | + /** |
| 125 | + * Calculate total character count excluding markup |
| 126 | + */ |
| 127 | + public static int plainLength(String text) { |
| 128 | + return stripTags(text).length(); |
| 129 | + } |
| 130 | + |
| 131 | + /** |
| 132 | + * Get character at reveal index, accounting for speed modifiers. |
| 133 | + * Returns the actual character position and any accumulated delay. |
| 134 | + */ |
| 135 | + public static RevealInfo getRevealInfo(List<TextSpan> spans, int revealIndex) { |
| 136 | + int charCount = 0; |
| 137 | + int totalDelay = 0; |
| 138 | + float avgSpeed = 1.0f; |
| 139 | + |
| 140 | + for (TextSpan span : spans) { |
| 141 | + int spanLen = span.length(); |
| 142 | + if (charCount + spanLen > revealIndex) { |
| 143 | + // This span contains the reveal position |
| 144 | + int posInSpan = revealIndex - charCount; |
| 145 | + totalDelay += span.getDelayMs(); |
| 146 | + avgSpeed = span.getSpeedMultiplier(); |
| 147 | + return new RevealInfo(revealIndex, totalDelay, avgSpeed, span); |
| 148 | + } |
| 149 | + charCount += spanLen; |
| 150 | + totalDelay += span.getDelayMs(); |
| 151 | + } |
| 152 | + |
| 153 | + // Past end |
| 154 | + return new RevealInfo(charCount, totalDelay, 1.0f, null); |
| 155 | + } |
| 156 | + |
| 157 | + /** |
| 158 | + * Information about a reveal position |
| 159 | + */ |
| 160 | + public static class RevealInfo { |
| 161 | + public final int charIndex; |
| 162 | + public final int accumulatedDelayMs; |
| 163 | + public final float speedMultiplier; |
| 164 | + public final TextSpan currentSpan; |
| 165 | + |
| 166 | + public RevealInfo(int charIndex, int accumulatedDelayMs, float speedMultiplier, TextSpan currentSpan) { |
| 167 | + this.charIndex = charIndex; |
| 168 | + this.accumulatedDelayMs = accumulatedDelayMs; |
| 169 | + this.speedMultiplier = speedMultiplier; |
| 170 | + this.currentSpan = currentSpan; |
| 171 | + } |
| 172 | + } |
| 173 | +} |
0 commit comments