D1: ThreadLocal alert suppression moved from ItemCollar to CollarHelper.
onCollarRemoved() logic (kidnapper alert) moved to CollarHelper.
D2+D3: Deleted 17 V1 item classes + 4 V1-only interfaces:
ItemBind, ItemGag, ItemBlindfold, ItemCollar, ItemEarplugs, ItemMittens,
ItemColor, ItemClassicCollar, ItemShockCollar, ItemShockCollarAuto,
ItemGpsCollar, ItemChokeCollar, ItemHood, ItemMedicalGag,
IBondageItem, IHasGaggingEffect, IHasBlindingEffect, IAdjustable
D4: KidnapperTheme/KidnapperItemSelector/DispenserBehaviors migrated
from variant enums to string-based DataDrivenItemRegistry IDs.
D5: Deleted 11 variant enums + Generic* factories + ItemBallGag3D:
BindVariant, GagVariant, BlindfoldVariant, EarplugsVariant, MittensVariant,
GenericBind, GenericGag, GenericBlindfold, GenericEarplugs, GenericMittens
D6: ModItems cleaned — all V1 bondage registrations removed.
D7: ModCreativeTabs rewritten — iterates DataDrivenItemRegistry.
D8+D9: All V2 helpers cleaned (V1 fallbacks removed), orphan imports removed.
Zero V1 bondage code references remain (only Javadoc comments).
All bondage items are now data-driven via 47 JSON definitions.
568 lines
18 KiB
Java
568 lines
18 KiB
Java
package com.tiedup.remake.dialogue;
|
|
|
|
import static com.tiedup.remake.util.GameConstants.*;
|
|
|
|
import com.tiedup.remake.dialogue.EmotionalContext.EmotionType;
|
|
import com.tiedup.remake.state.IBondageState;
|
|
import com.tiedup.remake.v2.bondage.component.ComponentType;
|
|
import com.tiedup.remake.v2.bondage.component.GaggingComponent;
|
|
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
|
import com.tiedup.remake.util.GagMaterial;
|
|
import com.tiedup.remake.util.PhoneticMapper;
|
|
import com.tiedup.remake.util.SyllableAnalyzer;
|
|
import java.util.List;
|
|
import java.util.Random;
|
|
import net.minecraft.ChatFormatting;
|
|
import net.minecraft.network.chat.Component;
|
|
import net.minecraft.world.effect.MobEffectInstance;
|
|
import net.minecraft.world.effect.MobEffects;
|
|
import net.minecraft.world.entity.LivingEntity;
|
|
import net.minecraft.world.item.ItemStack;
|
|
|
|
/**
|
|
*
|
|
* <p>Features:
|
|
* <ul>
|
|
* <li>Phonetic-based transformation preserving word structure</li>
|
|
* <li>Syllable-aware processing for natural rhythm</li>
|
|
* <li>Emotional context detection and expression</li>
|
|
* <li>Material-specific bleed-through rates</li>
|
|
* <li>Progressive comprehension (partial understanding)</li>
|
|
* </ul>
|
|
*/
|
|
public class GagTalkManager {
|
|
|
|
private static final Random RANDOM = new Random();
|
|
|
|
private static final String[] CRIT_FAIL_SOUNDS = {
|
|
"Mmph!!",
|
|
"Mmmph...",
|
|
"Hmpf!",
|
|
"Mmm...",
|
|
"Mph?!",
|
|
"Nnnnh!",
|
|
"Hffff!",
|
|
"P-pph!",
|
|
};
|
|
|
|
/**
|
|
* Process a gagged message for any IBondageState entity.
|
|
*
|
|
* @param kidnapped The kidnapped entity (Player, EntityDamsel, etc.)
|
|
* @param gagStack The gag item stack
|
|
* @param originalMessage The original message before gagging
|
|
* @return The muffled message component
|
|
*/
|
|
public static Component processGagMessage(
|
|
IBondageState kidnapped,
|
|
ItemStack gagStack,
|
|
String originalMessage
|
|
) {
|
|
LivingEntity entity = kidnapped.asLivingEntity();
|
|
GagMaterial material = GagMaterial.CLOTH;
|
|
// V2: check data-driven GaggingComponent first
|
|
GaggingComponent gaggingComp = DataDrivenBondageItem.getComponent(
|
|
gagStack, ComponentType.GAGGING, GaggingComponent.class);
|
|
if (gaggingComp != null && gaggingComp.getMaterial() != null) {
|
|
material = gaggingComp.getMaterial();
|
|
}
|
|
|
|
// 1. EFFET DE SUFFOCATION (Si message trop long)
|
|
applySuffocationEffects(entity, originalMessage.length(), material);
|
|
|
|
// 2. CHANCE D'ECHEC CRITIQUE
|
|
Component critFailResult = checkCriticalFailure(
|
|
kidnapped,
|
|
gagStack,
|
|
originalMessage.length(),
|
|
material
|
|
);
|
|
if (critFailResult != null) {
|
|
return critFailResult;
|
|
}
|
|
|
|
// 3. DETECT OVERALL MESSAGE EMOTION
|
|
EmotionType messageEmotion = EmotionalContext.detectEmotion(
|
|
originalMessage
|
|
);
|
|
|
|
// 4. CONSTRUCTION DU MESSAGE V4
|
|
StringBuilder muffled = new StringBuilder();
|
|
String[] words = originalMessage.split("\\s+");
|
|
|
|
for (int i = 0; i < words.length; i++) {
|
|
String word = words[i];
|
|
|
|
// Progressive comprehension: longer messages get harder to understand
|
|
float positionPenalty = (i > 5) ? 0.05f * (i - 5) : 0.0f;
|
|
float baseComp = material.getComprehension();
|
|
float effectiveComprehension = Math.max(
|
|
0,
|
|
baseComp - positionPenalty
|
|
);
|
|
|
|
// Apply emotional intensity modifier
|
|
effectiveComprehension /= EmotionalContext.getIntensityMultiplier(
|
|
messageEmotion
|
|
);
|
|
|
|
// Whispering is clearer
|
|
if (EmotionalContext.shouldPreserveMore(messageEmotion)) {
|
|
effectiveComprehension *= 1.5f;
|
|
}
|
|
|
|
// Material-specific interjections (rare, natural placement)
|
|
if (RANDOM.nextFloat() < 0.03f && i > 0 && i < words.length - 1) {
|
|
muffled.append(getMaterialInterjection(material)).append(" ");
|
|
}
|
|
|
|
// Movement affects clarity
|
|
if (entity.isSprinting() || !entity.onGround()) {
|
|
effectiveComprehension *= 0.7f;
|
|
}
|
|
|
|
// Three-tier comprehension:
|
|
// - Full pass: word understood completely
|
|
// - Partial: first letter(s) + muffled rest
|
|
// - None: fully muffled
|
|
float roll = RANDOM.nextFloat();
|
|
if (roll < effectiveComprehension * 0.4f) {
|
|
// Full word passes through
|
|
muffled.append(word);
|
|
} else if (roll < effectiveComprehension) {
|
|
// Partial: first letter(s) visible + muffled rest
|
|
muffled.append(generatePartiallyMuffledWord(word, material));
|
|
} else {
|
|
// Fully muffled
|
|
muffled.append(generateMuffledWord(word, material));
|
|
}
|
|
|
|
// Word separator
|
|
if (i < words.length - 1) {
|
|
if (RANDOM.nextFloat() < 0.1f) {
|
|
muffled.append("-");
|
|
} else {
|
|
muffled.append(" ");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. PENSEE INTERNE (Visible seulement pour l'entite)
|
|
kidnapped.sendMessage(
|
|
Component.literal("(")
|
|
.append(
|
|
Component.literal(originalMessage).withStyle(
|
|
ChatFormatting.ITALIC
|
|
)
|
|
)
|
|
.append(")")
|
|
.withStyle(ChatFormatting.GRAY),
|
|
false
|
|
);
|
|
|
|
return Component.literal(muffled.toString());
|
|
}
|
|
|
|
/**
|
|
* Generate a partially muffled word - first part recognizable, rest muffled.
|
|
*/
|
|
private static String generatePartiallyMuffledWord(
|
|
String original,
|
|
GagMaterial material
|
|
) {
|
|
if (original.isEmpty()) return "";
|
|
if (original.length() <= 2) return original;
|
|
|
|
// Find a good split point (after first consonant cluster + vowel)
|
|
int splitPoint = findNaturalSplitPoint(original);
|
|
|
|
String visible = original.substring(0, splitPoint);
|
|
String toMuffle = original.substring(splitPoint);
|
|
|
|
if (toMuffle.isEmpty()) {
|
|
return visible;
|
|
}
|
|
|
|
return visible + generateMuffledWord(toMuffle, material);
|
|
}
|
|
|
|
/**
|
|
* Find a natural split point in a word for partial muffling.
|
|
*/
|
|
private static int findNaturalSplitPoint(String word) {
|
|
if (word.length() <= 2) return word.length();
|
|
|
|
// Try to split after first syllable-ish structure
|
|
boolean foundVowel = false;
|
|
for (int i = 0; i < word.length() && i < 4; i++) {
|
|
char c = Character.toLowerCase(word.charAt(i));
|
|
boolean isVowel = "aeiouy".indexOf(c) >= 0;
|
|
|
|
if (isVowel) {
|
|
foundVowel = true;
|
|
} else if (foundVowel) {
|
|
// Found consonant after vowel - good split point
|
|
return i;
|
|
}
|
|
}
|
|
|
|
// Default: first 1-2 characters
|
|
return Math.min(2, word.length());
|
|
}
|
|
|
|
/**
|
|
* Generate a fully muffled word using phonetic transformation.
|
|
*/
|
|
private static String generateMuffledWord(
|
|
String original,
|
|
GagMaterial material
|
|
) {
|
|
if (original == null || original.isEmpty()) return "";
|
|
|
|
// Extract and preserve punctuation
|
|
String punctuation = extractTrailingPunctuation(original);
|
|
String cleanWord = original.replaceAll("[^a-zA-Z]", "");
|
|
|
|
if (cleanWord.isEmpty()) {
|
|
return original; // Pure punctuation, keep as-is
|
|
}
|
|
|
|
// Preserve original case pattern
|
|
boolean wasAllCaps =
|
|
cleanWord.length() >= 2 &&
|
|
cleanWord.equals(cleanWord.toUpperCase());
|
|
|
|
// Split into syllables for rhythm preservation
|
|
List<String> syllables = SyllableAnalyzer.splitIntoSyllables(cleanWord);
|
|
|
|
StringBuilder result = new StringBuilder();
|
|
float baseBleed = material.getComprehension();
|
|
|
|
for (
|
|
int syllableIdx = 0;
|
|
syllableIdx < syllables.size();
|
|
syllableIdx++
|
|
) {
|
|
String syllable = syllables.get(syllableIdx);
|
|
|
|
// First syllable gets bonus bleed (more recognizable)
|
|
float syllableBleed = (syllableIdx == 0)
|
|
? baseBleed * 1.3f
|
|
: baseBleed;
|
|
|
|
// Stressed syllables are clearer
|
|
if (
|
|
SyllableAnalyzer.isStressedSyllable(
|
|
syllable,
|
|
syllableIdx,
|
|
syllables.size()
|
|
)
|
|
) {
|
|
syllableBleed *= 1.2f;
|
|
}
|
|
|
|
StringBuilder muffledSyllable = new StringBuilder();
|
|
char prevOutput = 0;
|
|
|
|
for (int i = 0; i < syllable.length(); i++) {
|
|
char c = syllable.charAt(i);
|
|
|
|
// Calculate bleed for this specific phoneme
|
|
float phonemeBleed =
|
|
syllableBleed * material.getBleedRateFor(c);
|
|
|
|
// Apply phonetic mapping
|
|
String mapped = PhoneticMapper.mapPhoneme(
|
|
c,
|
|
material,
|
|
phonemeBleed
|
|
);
|
|
|
|
// Avoid excessive repetition (mmmmm -> mm)
|
|
if (shouldSkipRepetition(mapped, prevOutput, muffledSyllable)) {
|
|
continue;
|
|
}
|
|
|
|
muffledSyllable.append(mapped);
|
|
|
|
if (!mapped.isEmpty()) {
|
|
prevOutput = mapped.charAt(mapped.length() - 1);
|
|
}
|
|
}
|
|
|
|
result.append(muffledSyllable);
|
|
|
|
// Occasional syllable separator for multi-syllable words
|
|
if (
|
|
syllableIdx < syllables.size() - 1 && RANDOM.nextFloat() < 0.2f
|
|
) {
|
|
result.append("-");
|
|
}
|
|
}
|
|
|
|
String muffled = result.toString();
|
|
|
|
// Collapse excessive repetitions (max 2 of same letter)
|
|
muffled = collapseRepetitions(muffled, 2);
|
|
|
|
// Ensure we have something
|
|
if (muffled.isEmpty()) {
|
|
muffled =
|
|
material.getDominantConsonant() + material.getDominantVowel();
|
|
}
|
|
|
|
// Preserve ALL CAPS if original was all caps
|
|
if (wasAllCaps) {
|
|
muffled = muffled.toUpperCase();
|
|
}
|
|
|
|
return muffled + punctuation;
|
|
}
|
|
|
|
/**
|
|
* Check if we should skip adding a mapped sound to avoid excessive repetition.
|
|
*/
|
|
private static boolean shouldSkipRepetition(
|
|
String mapped,
|
|
char prevOutput,
|
|
StringBuilder current
|
|
) {
|
|
if (mapped.isEmpty()) return true;
|
|
|
|
char firstMapped = mapped.charAt(0);
|
|
|
|
// Skip if same character repeated more than twice
|
|
if (firstMapped == prevOutput) {
|
|
int repeatCount = 0;
|
|
for (
|
|
int i = current.length() - 1;
|
|
i >= 0 && i >= current.length() - 3;
|
|
i--
|
|
) {
|
|
if (current.charAt(i) == firstMapped) {
|
|
repeatCount++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if (repeatCount >= 2) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Collapse runs of repeated characters to a maximum count.
|
|
* "uuuuu" with maxRepeat=2 becomes "uu"
|
|
*/
|
|
private static String collapseRepetitions(String text, int maxRepeat) {
|
|
if (text == null || text.length() <= maxRepeat) {
|
|
return text;
|
|
}
|
|
|
|
StringBuilder result = new StringBuilder();
|
|
char prev = 0;
|
|
int count = 0;
|
|
|
|
for (int i = 0; i < text.length(); i++) {
|
|
char c = text.charAt(i);
|
|
char lower = Character.toLowerCase(c);
|
|
char prevLower = Character.toLowerCase(prev);
|
|
|
|
if (lower == prevLower && Character.isLetter(c)) {
|
|
count++;
|
|
if (count <= maxRepeat) {
|
|
result.append(c);
|
|
}
|
|
} else {
|
|
result.append(c);
|
|
count = 1;
|
|
}
|
|
prev = c;
|
|
}
|
|
|
|
return result.toString();
|
|
}
|
|
|
|
/**
|
|
* Extract trailing punctuation from a word.
|
|
*/
|
|
private static String extractTrailingPunctuation(String word) {
|
|
StringBuilder punct = new StringBuilder();
|
|
for (int i = word.length() - 1; i >= 0; i--) {
|
|
char c = word.charAt(i);
|
|
if (!Character.isLetter(c)) {
|
|
punct.insert(0, c);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return punct.toString();
|
|
}
|
|
|
|
/**
|
|
* Get a material-specific interjection sound.
|
|
*/
|
|
private static String getMaterialInterjection(GagMaterial material) {
|
|
return switch (material) {
|
|
case BALL -> "*hhuup*";
|
|
case TAPE -> "*mmn*";
|
|
case STUFFED, SPONGE -> "*mm*";
|
|
case RING -> "*aah*";
|
|
case BITE -> "*ngh*";
|
|
case LATEX -> "*uuh*";
|
|
case BAGUETTE -> "*nom*";
|
|
case PANEL -> "*mmph*";
|
|
default -> "*mph*";
|
|
};
|
|
}
|
|
|
|
// SUFFOCATION & CRITICAL FAILURE HELPERS
|
|
|
|
/**
|
|
* Apply suffocation effects if message is too long.
|
|
* Long messages through restrictive gags cause slowness and potential blindness.
|
|
*
|
|
* @param entity The entity speaking
|
|
* @param messageLength The length of the message
|
|
* @param material The gag material type
|
|
*/
|
|
private static void applySuffocationEffects(
|
|
LivingEntity entity,
|
|
int messageLength,
|
|
GagMaterial material
|
|
) {
|
|
if (
|
|
messageLength > GAG_MAX_MESSAGE_LENGTH_BEFORE_SUFFOCATION &&
|
|
material != GagMaterial.CLOTH
|
|
) {
|
|
entity.addEffect(
|
|
new MobEffectInstance(
|
|
MobEffects.MOVEMENT_SLOWDOWN,
|
|
GAG_SUFFOCATION_SLOWNESS_DURATION,
|
|
1
|
|
)
|
|
);
|
|
if (RANDOM.nextFloat() < GAG_SUFFOCATION_BLINDNESS_CHANCE) {
|
|
entity.addEffect(
|
|
new MobEffectInstance(
|
|
MobEffects.BLINDNESS,
|
|
GAG_SUFFOCATION_BLINDNESS_DURATION,
|
|
0
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for critical failure when speaking through gag.
|
|
* Longer messages have higher chance of complete muffling.
|
|
*
|
|
* @param kidnapped The kidnapped entity
|
|
* @param gagStack The gag item stack
|
|
* @param messageLength The length of the message
|
|
* @param material The gag material type
|
|
* @return Critical fail Component if failed, null otherwise
|
|
*/
|
|
private static Component checkCriticalFailure(
|
|
IBondageState kidnapped,
|
|
ItemStack gagStack,
|
|
int messageLength,
|
|
GagMaterial material
|
|
) {
|
|
float critChance =
|
|
GAG_BASE_CRITICAL_FAIL_CHANCE +
|
|
(messageLength * GAG_LENGTH_CRITICAL_FACTOR);
|
|
|
|
if (RANDOM.nextFloat() < critChance && material != GagMaterial.CLOTH) {
|
|
kidnapped.sendMessage(
|
|
Component.translatable(
|
|
"chat.tiedup.gag.crit_fail",
|
|
gagStack.getHoverName()
|
|
).withStyle(ChatFormatting.RED),
|
|
true
|
|
);
|
|
return Component.literal(
|
|
CRIT_FAIL_SOUNDS[RANDOM.nextInt(CRIT_FAIL_SOUNDS.length)]
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// MCA INTEGRATION METHODS
|
|
|
|
/**
|
|
* Transform a message to gagged speech without side effects.
|
|
* For use with MCA villagers and AI chat.
|
|
*
|
|
* <p>Unlike {@link #processGagMessage}, this method:
|
|
* <ul>
|
|
* <li>Does not apply suffocation effects</li>
|
|
* <li>Does not show internal thoughts</li>
|
|
* <li>Does not have critical fail chance</li>
|
|
* <li>Simply returns the muffled text</li>
|
|
* </ul>
|
|
*
|
|
* @param originalMessage The original message to transform
|
|
* @param gagStack The gag item stack (determines material)
|
|
* @return The muffled message string
|
|
*/
|
|
public static String transformToGaggedSpeech(
|
|
String originalMessage,
|
|
ItemStack gagStack
|
|
) {
|
|
if (originalMessage == null || originalMessage.isEmpty()) {
|
|
return "";
|
|
}
|
|
|
|
GagMaterial material = GagMaterial.CLOTH;
|
|
if (gagStack != null && !gagStack.isEmpty()) {
|
|
// V2: check data-driven GaggingComponent first
|
|
GaggingComponent comp = DataDrivenBondageItem.getComponent(
|
|
gagStack, ComponentType.GAGGING, GaggingComponent.class);
|
|
if (comp != null && comp.getMaterial() != null) {
|
|
material = comp.getMaterial();
|
|
}
|
|
}
|
|
|
|
StringBuilder muffled = new StringBuilder();
|
|
String[] words = originalMessage.split("\\s+");
|
|
|
|
for (int i = 0; i < words.length; i++) {
|
|
String word = words[i];
|
|
|
|
// Three-tier comprehension for MCA as well
|
|
float comp = material.getComprehension();
|
|
float roll = RANDOM.nextFloat();
|
|
|
|
if (roll < comp * 0.4f) {
|
|
muffled.append(word);
|
|
} else if (roll < comp) {
|
|
muffled.append(generatePartiallyMuffledWord(word, material));
|
|
} else {
|
|
muffled.append(generateMuffledWord(word, material));
|
|
}
|
|
|
|
if (i < words.length - 1) {
|
|
muffled.append(RANDOM.nextFloat() < 0.1f ? "-" : " ");
|
|
}
|
|
}
|
|
|
|
return muffled.toString();
|
|
}
|
|
|
|
/**
|
|
* Transform a message to gagged speech using default cloth gag.
|
|
* Convenience method for when gag item is not available.
|
|
*
|
|
* @param originalMessage The original message to transform
|
|
* @return The muffled message string
|
|
*/
|
|
public static String transformToGaggedSpeech(String originalMessage) {
|
|
return transformToGaggedSpeech(originalMessage, ItemStack.EMPTY);
|
|
}
|
|
}
|