From baa91c9829f834a7398f4e44ffb61c4eb5d06d35 Mon Sep 17 00:00:00 2001 From: ZeroXPatch <151591078+ZeroXPatch@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:28:41 -0600 Subject: [PATCH] Cache social entries and use game-time bark cooldowns --- NegativeRelations/IGenericModConfigMenuApi.cs | 50 ++ NegativeRelations/ModConfig.cs | 21 + NegativeRelations/ModEntry.cs | 600 ++++++++++++++++++ NegativeRelations/NegativeRelations.csproj | 12 + NegativeRelations/i18n/default.json | 59 ++ NegativeRelations/manifest.json | 10 + 6 files changed, 752 insertions(+) create mode 100644 NegativeRelations/IGenericModConfigMenuApi.cs create mode 100644 NegativeRelations/ModConfig.cs create mode 100644 NegativeRelations/ModEntry.cs create mode 100644 NegativeRelations/NegativeRelations.csproj create mode 100644 NegativeRelations/i18n/default.json create mode 100644 NegativeRelations/manifest.json diff --git a/NegativeRelations/IGenericModConfigMenuApi.cs b/NegativeRelations/IGenericModConfigMenuApi.cs new file mode 100644 index 0000000..263fa90 --- /dev/null +++ b/NegativeRelations/IGenericModConfigMenuApi.cs @@ -0,0 +1,50 @@ +using System; +using StardewModdingAPI; + +namespace NegativeRelations +{ + /// + /// Minimal GMCM API used by this mod. + /// + public interface IGenericModConfigMenuApi + { + void Register(IManifest mod, Action reset, Action save, bool titleScreenOnly = false); + + void AddSectionTitle(IManifest mod, Func text, Func tooltip = null); + + void AddNumberOption( + IManifest mod, + Func getValue, + Action setValue, + Func name, + Func tooltip = null, + int? min = null, + int? max = null, + int? interval = null, + Func formatValue = null, + string fieldId = null + ); + + void AddBoolOption( + IManifest mod, + Func getValue, + Action setValue, + Func name, + Func tooltip = null, + string fieldId = null + ); + + void AddNumberOption( + IManifest mod, + Func getValue, + Action setValue, + Func name, + Func tooltip = null, + float? min = null, + float? max = null, + float? interval = null, + Func formatValue = null, + string fieldId = null + ); + } +} diff --git a/NegativeRelations/ModConfig.cs b/NegativeRelations/ModConfig.cs new file mode 100644 index 0000000..abbb9a3 --- /dev/null +++ b/NegativeRelations/ModConfig.cs @@ -0,0 +1,21 @@ +namespace NegativeRelations +{ + public class ModConfig + { + public bool EnableMod { get; set; } = true; + + public int RecoveryPerDay { get; set; } = 5; + + public int BarkRadiusTiles { get; set; } = 3; + + public float BarkChance { get; set; } = 0.08f; + + public int BarkCooldownMinutes { get; set; } = 8; + + public float TalkOverrideChance { get; set; } = 0.30f; + + public bool EnableBarks { get; set; } = true; + + public bool EnableTalkOverride { get; set; } = true; + } +} diff --git a/NegativeRelations/ModEntry.cs b/NegativeRelations/ModEntry.cs new file mode 100644 index 0000000..a86179c --- /dev/null +++ b/NegativeRelations/ModEntry.cs @@ -0,0 +1,600 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; + +namespace NegativeRelations +{ + public class ModEntry : Mod + { + private ModConfig Config = new(); + private readonly Dictionary barkCooldowns = new(); + private readonly Dictionary barkCooldownUntil = new(); + private List<(NPC npc, Rectangle bounds)>? cachedSocialEntries; + private int cachedSocialTabIndex = -1; + private bool cachedSocialForMenu; + private readonly Random random = new(); + private bool suppressDialogueOverride; + + private List talkTier1Lines = new(); + private List talkTier2Lines = new(); + private List talkTier3Lines = new(); + private List barkTier2Lines = new(); + private List barkTier3Lines = new(); + + public override void Entry(IModHelper helper) + { + this.Config = helper.ReadConfig(); + + helper.Events.GameLoop.GameLaunched += this.OnGameLaunched; + helper.Events.GameLoop.DayStarted += this.OnDayStarted; + helper.Events.Player.GiftGiven += this.OnGiftGiven; + helper.Events.Display.MenuChanged += this.OnMenuChanged; + helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + helper.Events.Display.RenderedActiveMenu += this.OnRenderedActiveMenu; + + this.LoadDialogueLines(); + } + + private void OnGameLaunched(object? sender, GameLaunchedEventArgs e) + { + var gmcm = this.Helper.ModRegistry.GetApi("spacechase0.GenericModConfigMenu"); + if (gmcm is null) + { + return; + } + + gmcm.Register(this.ModManifest, () => this.Config = new ModConfig(), () => this.Helper.WriteConfig(this.Config)); + + gmcm.AddSectionTitle(this.ModManifest, () => this.Helper.Translation.Get("gmcm.section.general")); + gmcm.AddBoolOption( + this.ModManifest, + () => this.Config.EnableMod, + value => this.Config.EnableMod = value, + () => this.Helper.Translation.Get("gmcm.option.enableMod.name"), + () => this.Helper.Translation.Get("gmcm.option.enableMod.desc") + ); + gmcm.AddNumberOption( + this.ModManifest, + () => this.Config.RecoveryPerDay, + value => this.Config.RecoveryPerDay = value, + () => this.Helper.Translation.Get("gmcm.option.recovery.name"), + () => this.Helper.Translation.Get("gmcm.option.recovery.desc"), + min: 0, + max: 50 + ); + + gmcm.AddSectionTitle(this.ModManifest, () => this.Helper.Translation.Get("gmcm.section.dialogue")); + gmcm.AddBoolOption( + this.ModManifest, + () => this.Config.EnableTalkOverride, + value => this.Config.EnableTalkOverride = value, + () => this.Helper.Translation.Get("gmcm.option.talkOverride.name"), + () => this.Helper.Translation.Get("gmcm.option.talkOverride.desc") + ); + gmcm.AddNumberOption( + this.ModManifest, + () => this.Config.TalkOverrideChance, + value => this.Config.TalkOverrideChance = value, + () => this.Helper.Translation.Get("gmcm.option.talkChance.name"), + () => this.Helper.Translation.Get("gmcm.option.talkChance.desc"), + min: 0f, + max: 1f, + interval: 0.01f, + formatValue: val => $"{val:P0}" + ); + + gmcm.AddSectionTitle(this.ModManifest, () => this.Helper.Translation.Get("gmcm.section.barks")); + gmcm.AddBoolOption( + this.ModManifest, + () => this.Config.EnableBarks, + value => this.Config.EnableBarks = value, + () => this.Helper.Translation.Get("gmcm.option.barks.name"), + () => this.Helper.Translation.Get("gmcm.option.barks.desc") + ); + gmcm.AddNumberOption( + this.ModManifest, + () => this.Config.BarkRadiusTiles, + value => this.Config.BarkRadiusTiles = value, + () => this.Helper.Translation.Get("gmcm.option.barkRadius.name"), + () => this.Helper.Translation.Get("gmcm.option.barkRadius.desc"), + min: 1, + max: 8 + ); + gmcm.AddNumberOption( + this.ModManifest, + () => this.Config.BarkChance, + value => this.Config.BarkChance = value, + () => this.Helper.Translation.Get("gmcm.option.barkChance.name"), + () => this.Helper.Translation.Get("gmcm.option.barkChance.desc"), + min: 0f, + max: 1f, + interval: 0.01f, + formatValue: val => $"{val:P0}" + ); + gmcm.AddNumberOption( + this.ModManifest, + () => this.Config.BarkCooldownMinutes, + value => this.Config.BarkCooldownMinutes = value, + () => this.Helper.Translation.Get("gmcm.option.barkCooldown.name"), + () => this.Helper.Translation.Get("gmcm.option.barkCooldown.desc"), + min: 1, + max: 30 + ); + } + + private void OnDayStarted(object? sender, DayStartedEventArgs e) + { + if (!Context.IsWorldReady || !this.Config.EnableMod) + { + return; + } + + var player = Game1.player; + var keys = player.modData.Keys.Where(key => key.StartsWith(this.GetNegKeyPrefix(), StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (string key in keys) + { + string npcName = key.Substring(this.GetNegKeyPrefix().Length); + int current = this.GetNegPoints(npcName); + if (current < 0 && this.Config.RecoveryPerDay > 0) + { + int recovered = Math.Min(0, current + this.Config.RecoveryPerDay); + this.SetNegPoints(npcName, recovered); + } + } + + this.barkCooldowns.Clear(); + } + + private void OnGiftGiven(object? sender, GiftGivenEventArgs e) + { + if (!Context.IsWorldReady || !this.Config.EnableMod) + { + return; + } + + if (e.Npc is null || e.Gift is null) + { + return; + } + + if (this.IsFestivalOrEvent()) + { + return; + } + + GiftTaste taste = e.Npc.getGiftTasteForThisItem(e.Gift); + + int delta = 0; + if (taste == GiftTaste.Dislike) + { + delta = -50; + } + else if (taste == GiftTaste.Hate) + { + delta = -200; + } + + if (delta < 0) + { + string npcName = e.Npc.Name; + int updated = this.GetNegPoints(npcName) + delta; + this.SetNegPoints(npcName, updated); + } + } + + private void OnMenuChanged(object? sender, MenuChangedEventArgs e) + { + if (this.suppressDialogueOverride) + { + this.suppressDialogueOverride = false; + return; + } + + this.cachedSocialEntries = null; + this.cachedSocialForMenu = false; + this.cachedSocialTabIndex = -1; + + if (!Context.IsWorldReady || !this.Config.EnableMod || !this.Config.EnableTalkOverride) + { + return; + } + + if (this.IsFestivalOrEvent()) + { + return; + } + + if (e.NewMenu is not StardewValley.Menus.DialogueBox || Game1.currentSpeaker is not NPC npc) + { + return; + } + + int tier = this.GetTier(this.GetNegPoints(npc.Name)); + if (tier <= 0) + { + return; + } + + if (this.random.NextDouble() > this.Config.TalkOverrideChance) + { + return; + } + + string? line = this.GetTalkLineForTier(tier); + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + this.suppressDialogueOverride = true; + Game1.drawDialogue(npc, line); + } + + private void OnUpdateTicked(object? sender, UpdateTickedEventArgs e) + { + if (!Context.IsWorldReady || !this.Config.EnableMod || !this.Config.EnableBarks) + { + return; + } + + if (!e.IsMultipleOf(30)) + { + return; + } + + if (this.IsFestivalOrEvent() || Game1.activeClickableMenu is not null) + { + return; + } + + this.TryDoBarks(); + } + + private void OnRenderedActiveMenu(object? sender, RenderedActiveMenuEventArgs e) + { + if (!Context.IsWorldReady || !this.Config.EnableMod) + { + return; + } + + if (this.IsFestivalOrEvent()) + { + return; + } + + if (Game1.activeClickableMenu is not GameMenu gameMenu) + { + return; + } + + IClickableMenu? currentPage = this.GetCurrentGameMenuPage(gameMenu); + if (currentPage is not SocialPage socialPage) + { + return; + } + + this.DrawNegativeHearts(socialPage, e.SpriteBatch); + } + + private void TryDoBarks() + { + if (Game1.currentLocation is null || Game1.player is null) + { + return; + } + + var player = Game1.player; + Vector2 playerTile = player.TilePoint.ToVector2(); + int nowGameMinutes = this.GetAbsoluteGameMinutes(); + + foreach (NPC npc in Game1.currentLocation.characters.OfType()) + { + if (npc.IsInvisible || !npc.isVillager()) + { + continue; + } + + int tier = this.GetTier(this.GetNegPoints(npc.Name)); + if (tier < 2) + { + continue; + } + + if (!Utility.isOnScreen(npc.Position, 256)) + { + continue; + } + + double distance = Vector2.Distance(npc.TilePoint.ToVector2(), playerTile); + if (distance > this.Config.BarkRadiusTiles) + { + continue; + } + + if (this.barkCooldownUntil.TryGetValue(npc.Name, out int until) && nowGameMinutes < until) + { + continue; + } + + if (this.random.NextDouble() > this.Config.BarkChance) + { + continue; + } + + string? line = this.GetBarkLineForTier(tier); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + npc.showTextAboveHead(line); + int cooldownMinutes = this.Config.BarkCooldownMinutes; + this.barkCooldownUntil[npc.Name] = nowGameMinutes + cooldownMinutes; + } + } + + private string? GetTalkLineForTier(int tier) + { + return tier switch + { + 1 => this.PickRandomLine(this.talkTier1Lines), + 2 => this.PickRandomLine(this.talkTier2Lines), + _ => this.PickRandomLine(this.talkTier3Lines) + }; + } + + private string? GetBarkLineForTier(int tier) + { + return tier >= 3 ? this.PickRandomLine(this.barkTier3Lines) : this.PickRandomLine(this.barkTier2Lines); + } + + private string? PickRandomLine(IReadOnlyList lines) + { + if (lines.Count == 0) + { + return null; + } + + int index = this.random.Next(lines.Count); + return lines[index]; + } + + public int GetNegPoints(string npcName) + { + if (!Context.IsWorldReady || string.IsNullOrWhiteSpace(npcName)) + { + return 0; + } + + var modData = Game1.player.modData; + string key = this.GetNegKey(npcName); + if (modData.TryGetValue(key, out string? raw) && int.TryParse(raw, out int parsed)) + { + return Math.Clamp(parsed, -2500, 0); + } + + return 0; + } + + public void SetNegPoints(string npcName, int value) + { + if (!Context.IsWorldReady || string.IsNullOrWhiteSpace(npcName)) + { + return; + } + + int clamped = Math.Clamp(value, -2500, 0); + Game1.player.modData[this.GetNegKey(npcName)] = clamped.ToString(); + } + + public int GetTier(int negPoints) + { + if (negPoints == 0) + { + return 0; + } + + if (negPoints <= -1000) + { + return 3; + } + + if (negPoints <= -250) + { + return 2; + } + + return 1; + } + + private string GetNegKey(string npcName) => $"{this.ModManifest.UniqueID}/negPoints/{npcName}"; + + private string GetNegKeyPrefix() => $"{this.ModManifest.UniqueID}/negPoints/"; + + private bool IsFestivalOrEvent() + { + if (Game1.eventUp) + { + return true; + } + + return Game1.currentLocation?.currentEvent?.isFestival == true; + } + + private IClickableMenu? GetCurrentGameMenuPage(GameMenu menu) + { + var pagesField = this.Helper.Reflection.GetField>(menu, "pages", required: false); + List? pages = pagesField?.GetValue(); + if (pages is null || menu.currentTab < 0 || menu.currentTab >= pages.Count) + { + return null; + } + + return pages[menu.currentTab]; + } + + private void DrawNegativeHearts(SocialPage socialPage, SpriteBatch spriteBatch) + { + const int pointsPerHeart = 250; + const float heartScale = 4f; + const int heartSpriteWidth = 7; + const int heartSpriteHeight = 6; + const float heartSpacing = heartSpriteWidth * heartScale + 2f; + + foreach ((NPC npc, Rectangle bounds) in this.GetSocialEntriesCached(socialPage)) + { + int negPoints = this.GetNegPoints(npc.Name); + if (negPoints >= 0) + { + continue; + } + + int heartCount = Math.Min(10, (int)Math.Ceiling(Math.Abs(negPoints) / (float)pointsPerHeart)); + if (heartCount <= 0) + { + continue; + } + + float startX = bounds.Right - (heartSpacing * heartCount) - 16f; + float startY = bounds.Top + (bounds.Height / 2f) - (heartSpriteHeight * heartScale / 2f); + Rectangle heartSource = new(211, 428, heartSpriteWidth, heartSpriteHeight); + + for (int i = 0; i < heartCount; i++) + { + Vector2 position = new(startX + i * heartSpacing, startY); + spriteBatch.Draw(Game1.mouseCursors, position, heartSource, Color.Black, 0f, Vector2.Zero, heartScale, SpriteEffects.None, 1f); + } + } + } + + private IEnumerable<(NPC npc, Rectangle bounds)> GetSocialEntriesCached(SocialPage socialPage) + { + if (this.cachedSocialEntries is not null && this.cachedSocialForMenu && this.cachedSocialTabIndex == socialPage.currentTab) + { + return this.cachedSocialEntries; + } + + this.cachedSocialEntries = this.BuildSocialEntries(socialPage).ToList(); + this.cachedSocialForMenu = true; + this.cachedSocialTabIndex = socialPage.currentTab; + return this.cachedSocialEntries; + } + + private IEnumerable<(NPC npc, Rectangle bounds)> BuildSocialEntries(SocialPage socialPage) + { + List<(NPC, Rectangle)> results = new(); + foreach (string fieldName in this.GetSocialEntryFieldOrder(socialPage)) + { + var field = this.Helper.Reflection.GetField>(socialPage, fieldName, required: false); + IEnumerable? entries = field?.GetValue(); + if (entries is null) + { + continue; + } + + foreach (object entry in entries) + { + NPC? npc = this.Helper.Reflection.GetProperty(entry, "Character", required: false)?.GetValue() + ?? this.Helper.Reflection.GetField(entry, "character", required: false)?.GetValue() + ?? this.Helper.Reflection.GetProperty(entry, "npc", required: false)?.GetValue(); + Rectangle bounds = this.Helper.Reflection.GetProperty(entry, "Bounds", required: false)?.GetValue() + ?? this.Helper.Reflection.GetField(entry, "bounds", required: false)?.GetValue() + ?? Rectangle.Empty; + + if (npc is not null && bounds != Rectangle.Empty) + { + results.Add((npc, bounds)); + } + } + + if (results.Count > 0) + { + break; + } + } + + return results; + } + + private IEnumerable GetSocialEntryFieldOrder(SocialPage page) + { + yield return "socialEntries"; + yield return "_socialEntries"; + yield return "friends"; + yield return "slots"; + } + + private int GetAbsoluteGameMinutes() + { + int days = Game1.Date.TotalDays; + int hour = Game1.timeOfDay / 100; + int minute = Game1.timeOfDay % 100; + return days * 1440 + (hour * 60) + minute; + } + + private void LoadDialogueLines() + { + this.talkTier1Lines = this.ReadLines( + "talk.tier1", + "line1", + "line2", + "line3", + "line4", + "line5" + ); + this.talkTier2Lines = this.ReadLines( + "talk.tier2", + "line1", + "line2", + "line3", + "line4", + "line5" + ); + this.talkTier3Lines = this.ReadLines( + "talk.tier3", + "line1", + "line2", + "line3", + "line4", + "line5" + ); + this.barkTier2Lines = this.ReadLines( + "bark.tier2", + "line1", + "line2", + "line3", + "line4", + "line5" + ); + this.barkTier3Lines = this.ReadLines( + "bark.tier3", + "line1", + "line2", + "line3", + "line4", + "line5" + ); + } + + private List ReadLines(string prefix, params string[] keys) + { + List lines = new(); + foreach (string key in keys) + { + string text = this.Helper.Translation.Get($"{prefix}.{key}"); + if (!string.IsNullOrWhiteSpace(text)) + { + lines.Add(text); + } + } + + return lines; + } + } +} diff --git a/NegativeRelations/NegativeRelations.csproj b/NegativeRelations/NegativeRelations.csproj new file mode 100644 index 0000000..0bbf60c --- /dev/null +++ b/NegativeRelations/NegativeRelations.csproj @@ -0,0 +1,12 @@ + + + net6.0 + NegativeRelations + NegativeRelations + enable + enable + + + + + diff --git a/NegativeRelations/i18n/default.json b/NegativeRelations/i18n/default.json new file mode 100644 index 0000000..5053375 --- /dev/null +++ b/NegativeRelations/i18n/default.json @@ -0,0 +1,59 @@ +{ + "gmcm.section.general": "General", + "gmcm.section.dialogue": "Dialogue", + "gmcm.section.barks": "Passing Comments", + + "gmcm.option.enableMod.name": "Enable mod", + "gmcm.option.enableMod.desc": "Toggle all features on or off.", + + "gmcm.option.recovery.name": "Recovery per day", + "gmcm.option.recovery.desc": "How many negative points each NPC recovers toward neutral at day start.", + + "gmcm.option.talkOverride.name": "Colder dialogue", + "gmcm.option.talkOverride.desc": "Allow hostile dialogue to replace normal talk based on negative tiers.", + + "gmcm.option.talkChance.name": "Talk override chance", + "gmcm.option.talkChance.desc": "Chance that a normal conversation line is replaced by a negative one.", + + "gmcm.option.barks.name": "Enable passing comments", + "gmcm.option.barks.desc": "Allow hostile NPCs to say short comments when you walk near them.", + + "gmcm.option.barkRadius.name": "Bark radius (tiles)", + "gmcm.option.barkRadius.desc": "How close you need to be (in tiles) before a hostile NPC may bark at you.", + + "gmcm.option.barkChance.name": "Bark chance", + "gmcm.option.barkChance.desc": "Chance per check that a nearby hostile NPC says a passing comment.", + + "gmcm.option.barkCooldown.name": "Bark cooldown (minutes)", + "gmcm.option.barkCooldown.desc": "Minimum in-game minutes before the same NPC can bark again.", + + "talk.tier1.line1": "Yeah?", + "talk.tier1.line2": "Make it quick.", + "talk.tier1.line3": "What now?", + "talk.tier1.line4": "Huh.", + "talk.tier1.line5": "Need something?", + + "talk.tier2.line1": "Don't bother me.", + "talk.tier2.line2": "Go away.", + "talk.tier2.line3": "I'm busy.", + "talk.tier2.line4": "Find someone else to bug.", + "talk.tier2.line5": "Seriously?", + + "talk.tier3.line1": "I can't stand you.", + "talk.tier3.line2": "Just leave.", + "talk.tier3.line3": "Why are you here?", + "talk.tier3.line4": "You're the worst.", + "talk.tier3.line5": "Get lost.", + + "bark.tier2.line1": "Tch.", + "bark.tier2.line2": "Move.", + "bark.tier2.line3": "Watch it.", + "bark.tier2.line4": "Keep walking.", + "bark.tier2.line5": "Hm.", + + "bark.tier3.line1": "Ugh.", + "bark.tier3.line2": "Seriously?", + "bark.tier3.line3": "Leave me alone.", + "bark.tier3.line4": "Go away.", + "bark.tier3.line5": "..." +} diff --git a/NegativeRelations/manifest.json b/NegativeRelations/manifest.json new file mode 100644 index 0000000..7b20c64 --- /dev/null +++ b/NegativeRelations/manifest.json @@ -0,0 +1,10 @@ +{ + "Name": "Negative Relations", + "Author": "AI Assistant", + "Version": "1.0.0", + "Description": "Adds negative relationship tiers with hostile dialogue and barks.", + "UniqueID": "AI.NegativeRelations", + "EntryDll": "NegativeRelations.dll", + "MinimumApiVersion": "4.0.0", + "UpdateKeys": [] +}