diff --git a/Client/Localization/Chinese.json b/Client/Localization/Chinese.json index 15c1b7cfe..06a878d03 100644 --- a/Client/Localization/Chinese.json +++ b/Client/Localization/Chinese.json @@ -855,7 +855,9 @@ "MaxAcPlusPercent": "最大防御 + {0}%", "MaxMacPlusPercent": "最大魔法防御 + {0}%", "HealthRecoveryPlus": "生命恢复 + {0}", + "HealthRecoveryPlusPercent": "生命恢复 + {0}%", "ManaRecoveryPlus": "魔法恢复 + {0}", + "ManaRecoveryPlusPercent": "魔法恢复 + {0}%", "PoisonRecoveryPlus": "中毒恢复 + {0}", "AddsAgilityPlus": "敏捷 +{0}", "StrongPlus": "力量 + {0}", diff --git a/Client/Localization/English.json b/Client/Localization/English.json index b86403d50..4e0347a60 100644 --- a/Client/Localization/English.json +++ b/Client/Localization/English.json @@ -855,7 +855,9 @@ "MaxAcPlusPercent": "Max AC + {0}%", "MaxMacPlusPercent": "Max MAC + {0}%", "HealthRecoveryPlus": "Health Recovery + {0}", + "HealthRecoveryPlusPercent": "Health Recovery + {0}%", "ManaRecoveryPlus": "Mana Recovery + {0}", + "ManaRecoveryPlusPercent": "Mana Recovery + {0}%", "PoisonRecoveryPlus": "Poison Recovery + {0}", "AddsAgilityPlus": "Adds +{0} Agility", "StrongPlus": "Strong + {0}", diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index c67e3795d..603a04e25 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -3412,7 +3412,18 @@ private void DamageIndicator(S.DamageIndicator p) switch (p.Type) { case DamageType.Hit: //add damage level colours - obj.Damages.Add(new Damage(p.Damage.ToString("#,##0"), 1000, obj.Race == ObjectType.Player ? Color.Red : Color.White, 50)); + { + // Negative values are damage, positive values are healing/regen. + var value = p.Damage; + if (value == 0) break; + string text = value > 0 ? $"+{value:#,##0}" : value.ToString("#,##0"); + + Color colour; + // Keep legacy colour scheme (player red, others white) for consistency. + colour = obj.Race == ObjectType.Player ? Color.Red : Color.White; + + obj.Damages.Add(new Damage(text, 1000, colour, 50)); + } break; case DamageType.Miss: obj.Damages.Add(new Damage(GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.Miss), 1200, obj.Race == ObjectType.Player ? Color.LightCoral : Color.LightGray, 50)); @@ -7749,7 +7760,10 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid maxValue = 0; addValue = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.HP] : 0; - if (minValue > 0 || maxValue > 0 || addValue > 0) + bool isPotionHP = realItem.Type == ItemType.Potion; + string hpRange = realItem.HPRollRaw; // e.g. "10~100" or empty + + if (minValue > 0 || maxValue > 0 || addValue > 0 || (isPotionHP && !string.IsNullOrWhiteSpace(hpRange))) { count++; MirLabel MAXHPLabel = new MirLabel @@ -7759,8 +7773,12 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, - //Text = string.Format(realItem.Type == ItemType.Potion ? "HP + {0} Recovery" : "MAXHP + {0}", minValue + addValue) - Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxHpPlus), minValue + addValue) + (addValue > 0 ? $" (+{addValue})" : String.Empty) + Text = isPotionHP + ? GameLanguage.ClientTextMap.GetLocalization( + ClientTextKeys.HealthRecoveryPlus, + string.IsNullOrWhiteSpace(hpRange) ? (minValue + addValue).ToString() : hpRange + ) + (addValue > 0 ? $" (+{addValue})" : String.Empty) + : GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxHpPlus), minValue + addValue) + (addValue > 0 ? $" (+{addValue})" : String.Empty) }; ItemLabel.Size = new Size(Math.Max(ItemLabel.Size.Width, MAXHPLabel.DisplayRectangle.Right + 4), @@ -7776,7 +7794,10 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid maxValue = 0; addValue = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MP] : 0; - if (minValue > 0 || maxValue > 0 || addValue > 0) + bool isPotionMP = realItem.Type == ItemType.Potion; + string mpRange = realItem.MPRollRaw; // e.g. "10~100" or empty + + if (minValue > 0 || maxValue > 0 || addValue > 0 || (isPotionMP && !string.IsNullOrWhiteSpace(mpRange))) { count++; MirLabel MAXMPLabel = new MirLabel @@ -7786,8 +7807,12 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, - //Text = string.Format(realItem.Type == ItemType.Potion ? "MP + {0} Recovery" : "MAXMP + {0}", minValue + addValue) - Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxMpPlus), minValue + addValue) + (addValue > 0 ? $" (+{addValue})" : String.Empty) + Text = isPotionMP + ? GameLanguage.ClientTextMap.GetLocalization( + ClientTextKeys.ManaRecoveryPlus, + string.IsNullOrWhiteSpace(mpRange) ? (minValue + addValue).ToString() : mpRange + ) + (addValue > 0 ? $" (+{addValue})" : String.Empty) + : GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxMpPlus), minValue + addValue) + (addValue > 0 ? $" (+{addValue})" : String.Empty) }; ItemLabel.Size = new Size(Math.Max(ItemLabel.Size.Width, MAXMPLabel.DisplayRectangle.Right + 4), @@ -7812,7 +7837,9 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, - Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxHpPlusPercent), minValue + addValue) + Text = realItem.Type == ItemType.Potion + ? GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.HealthRecoveryPlusPercent), minValue + addValue) + : GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxHpPlusPercent), minValue + addValue) }; ItemLabel.Size = new Size(Math.Max(ItemLabel.Size.Width, MAXHPRATELabel.DisplayRectangle.Right + 4), @@ -7837,7 +7864,9 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, - Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxMpPlusPercent), minValue + addValue) + Text = realItem.Type == ItemType.Potion + ? GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.ManaRecoveryPlusPercent), minValue + addValue) + : GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaxMpPlusPercent), minValue + addValue) }; ItemLabel.Size = new Size(Math.Max(ItemLabel.Size.Width, MAXMPRATELabel.DisplayRectangle.Right + 4), diff --git a/Server.MirForms/Database/ItemInfoFormNew.cs b/Server.MirForms/Database/ItemInfoFormNew.cs index e2745ef50..744e10391 100644 --- a/Server.MirForms/Database/ItemInfoFormNew.cs +++ b/Server.MirForms/Database/ItemInfoFormNew.cs @@ -178,7 +178,8 @@ private void CreateDynamicColumns() { HeaderText = $"{strKey} {sign}", Name = "Stat" + stat.ToString(), - ValueType = typeof(int), + // HP/MP support fixed number OR "min~max" range strings. + ValueType = (stat == Stat.HP || stat == Stat.MP) ? typeof(string) : typeof(int), DataPropertyName = "Stat" + stat.ToString() }; @@ -268,7 +269,12 @@ private void PopulateTable() { if (stat == Stat.Unknown) continue; - row["Stat" + stat.ToString()] = item.Stats[stat]; + if (stat == Stat.HP) + row["StatHP"] = string.IsNullOrWhiteSpace(item.HPRollRaw) ? item.Stats[Stat.HP].ToString() : item.HPRollRaw; + else if (stat == Stat.MP) + row["StatMP"] = string.IsNullOrWhiteSpace(item.MPRollRaw) ? item.Stats[Stat.MP].ToString() : item.MPRollRaw; + else + row["Stat" + stat.ToString()] = item.Stats[stat]; } foreach (BindMode bind in BindEnums) @@ -382,6 +388,8 @@ private void SaveForm() item.ToolTip = row.Cells["ItemToolTip"].Value.ToString(); item.Stats.Clear(); + item.HPRollRaw = string.Empty; + item.MPRollRaw = string.Empty; item.Bind = BindMode.None; item.Unique = SpecialItemMode.None; @@ -389,11 +397,42 @@ private void SaveForm() { if (col.Name.StartsWith("Stat")) { - var stat = col.Name.Substring(4); + // HP/MP accept "N" or "N~M" (stored as strings on ItemInfo) + if (col.Name == "StatHP" || col.Name == "StatMP") + { + var text = (row.Cells[col.Name].Value?.ToString() ?? "").Trim(); - Stat enumStat = (Stat)Enum.Parse(typeof(Stat), stat); + if (string.IsNullOrWhiteSpace(text)) + { + if (col.Name == "StatHP") { item.HPRollRaw = ""; item.Stats[Stat.HP] = 0; } + else { item.MPRollRaw = ""; item.Stats[Stat.MP] = 0; } + } + else if (TryParseRangeOrNumber(text, out var isRange, out var min, out var max)) + { + if (isRange) + { + if (col.Name == "StatHP") { item.HPRollRaw = $"{min}~{max}"; item.Stats[Stat.HP] = 0; } + else { item.MPRollRaw = $"{min}~{max}"; item.Stats[Stat.MP] = 0; } + } + else + { + if (col.Name == "StatHP") { item.HPRollRaw = ""; item.Stats[Stat.HP] = min; } + else { item.MPRollRaw = ""; item.Stats[Stat.MP] = min; } + } + } + else + { + // Shouldn't happen due to validation + if (col.Name == "StatHP") { item.HPRollRaw = ""; item.Stats[Stat.HP] = 0; } + else { item.MPRollRaw = ""; item.Stats[Stat.MP] = 0; } + } - item.Stats[enumStat] = (int)row.Cells[col.Name].Value; + continue; + } + + var stat = col.Name.Substring(4); + Stat enumStat = (Stat)Enum.Parse(typeof(Stat), stat); + item.Stats[enumStat] = Convert.ToInt32(row.Cells[col.Name].Value ?? 0); } else if (col.Name.StartsWith("Bind")) { @@ -467,6 +506,22 @@ private void itemInfoGridView_CellValidating(object sender, DataGridViewCellVali itemInfoGridView.Rows[e.RowIndex].ErrorText = ""; + // HP/MP accept "N" or "N~M" + if (col.Name == "StatHP" || col.Name == "StatMP") + { + var s = (val ?? "").Trim(); + if (!string.IsNullOrEmpty(s) && !TryParseRangeOrNumber(s, out _, out _, out _)) + { + e.Cancel = true; + itemInfoGridView.Rows[e.RowIndex].ErrorText = "Enter a number or min~max (e.g. 20 or 20~40)."; + } + + if (!e.Cancel) + itemInfoGridView.Rows[e.RowIndex].Cells["Modified"].Value = true; + + return; + } + if (cell.OwningColumn.Name == "ItemName") { var existingRow = FindRowByItemName(val); @@ -519,6 +574,33 @@ private void itemInfoGridView_CellValidating(object sender, DataGridViewCellVali } } + // "N" or "N~M" (negatives allowed) + private static bool TryParseRangeOrNumber(string s, out bool isRange, out int min, out int max) + { + isRange = false; + min = max = 0; + if (string.IsNullOrWhiteSpace(s)) return false; + + if (s.Contains("~")) + { + var parts = s.Split('~'); + if (parts.Length != 2) return false; + if (!int.TryParse(parts[0].Trim(), out min)) return false; + if (!int.TryParse(parts[1].Trim(), out max)) return false; + if (min > max) (min, max) = (max, min); + isRange = true; + return true; + } + + if (int.TryParse(s.Trim(), out var v)) + { + min = max = v; + return true; + } + + return false; + } + private void rbtnViewAll_CheckedChanged(object sender, EventArgs e) { if (rbtnViewAll.Checked) diff --git a/Server/MirObjects/HeroObject.cs b/Server/MirObjects/HeroObject.cs index d8bec8a73..2ee7f209c 100644 --- a/Server/MirObjects/HeroObject.cs +++ b/Server/MirObjects/HeroObject.cs @@ -354,12 +354,35 @@ public override void UseItem(ulong id) switch (item.Info.Shape) { case 0: //NormalPotion - PotHealthAmount = (ushort)Math.Min(ushort.MaxValue, PotHealthAmount + item.Info.Stats[Stat.HP]); - PotManaAmount = (ushort)Math.Min(ushort.MaxValue, PotManaAmount + item.Info.Stats[Stat.MP]); + { + GetPotionRecovery(item, out int hpVal, out int mpVal); + + int newHP = PotHealthAmount + hpVal; + int newMP = PotManaAmount + mpVal; + + newHP = Math.Max(0, newHP); + newMP = Math.Max(0, newMP); + + PotHealthAmount = (ushort)Math.Min(ushort.MaxValue, newHP); + PotManaAmount = (ushort)Math.Min(ushort.MaxValue, newMP); + } break; case 1: //SunPotion - ChangeHP(item.Info.Stats[Stat.HP]); - ChangeMP(item.Info.Stats[Stat.MP]); + { + GetPotionRecovery(item, out int hpVal, out int mpVal); + + if (hpVal != 0) + { + ChangeHP(hpVal); + BroadcastDamageIndicator(DamageType.Hit, hpVal); + } + + if (mpVal != 0) + { + ChangeMP(mpVal); + BroadcastDamageIndicator(DamageType.Hit, mpVal); + } + } break; case 2: //MysteryWater if (UnlockCurse) diff --git a/Server/MirObjects/HumanObject.cs b/Server/MirObjects/HumanObject.cs index d20adf969..6ee46a18e 100644 --- a/Server/MirObjects/HumanObject.cs +++ b/Server/MirObjects/HumanObject.cs @@ -644,7 +644,11 @@ private void ProcessRegen() HealAmount = 0; } - if (manaRegen > 0) ChangeMP(manaRegen); + if (manaRegen > 0) + { + ChangeMP(manaRegen); + BroadcastDamageIndicator(DamageType.Hit, manaRegen); + } if (MP == Stats[Stat.MP]) PotManaAmount = 0; } private void ProcessPoison() @@ -1343,6 +1347,28 @@ protected bool CanUseItem(UserItem item) return true; } public virtual void UseItem(ulong id) { } + + protected static int CalcPercentAmount(int maxValue, int percent) + { + if (percent == 0 || maxValue <= 0) return 0; + + int absPercent = Math.Abs(percent); + long raw = (long)maxValue * absPercent; + + // Ceil to avoid 1% on small pools rounding down to 0. + int amount = (int)((raw + 99) / 100); + return percent > 0 ? amount : -amount; + } + + protected void GetPotionRecovery(UserItem item, out int hpVal, out int mpVal) + { + hpVal = item?.Info?.RollHP() ?? 0; + mpVal = item?.Info?.RollMP() ?? 0; + + // Percent-based recovery (for potions): add % of max HP/MP. + hpVal += CalcPercentAmount(Stats[Stat.HP], item?.GetTotal(Stat.HPRatePercent) ?? 0); + mpVal += CalcPercentAmount(Stats[Stat.MP], item?.GetTotal(Stat.MPRatePercent) ?? 0); + } protected void ConsumeItem(UserItem item, byte cost) { item.Count -= cost; diff --git a/Server/MirObjects/PlayerObject.cs b/Server/MirObjects/PlayerObject.cs index a7d8f6e1c..4a01b6e47 100644 --- a/Server/MirObjects/PlayerObject.cs +++ b/Server/MirObjects/PlayerObject.cs @@ -5788,12 +5788,35 @@ public override void UseItem(ulong id) switch (item.Info.Shape) { case 0: //NormalPotion - PotHealthAmount = (ushort)Math.Min(ushort.MaxValue, PotHealthAmount + item.Info.Stats[Stat.HP]); - PotManaAmount = (ushort)Math.Min(ushort.MaxValue, PotManaAmount + item.Info.Stats[Stat.MP]); + { + GetPotionRecovery(item, out int hpVal, out int mpVal); + + int newHP = PotHealthAmount + hpVal; + int newMP = PotManaAmount + mpVal; + + newHP = Math.Max(0, newHP); + newMP = Math.Max(0, newMP); + + PotHealthAmount = (ushort)Math.Min(ushort.MaxValue, newHP); + PotManaAmount = (ushort)Math.Min(ushort.MaxValue, newMP); + } break; case 1: //SunPotion - ChangeHP(item.Info.Stats[Stat.HP]); - ChangeMP(item.Info.Stats[Stat.MP]); + { + GetPotionRecovery(item, out int hpVal, out int mpVal); + + if (hpVal != 0) + { + ChangeHP(hpVal); + BroadcastDamageIndicator(DamageType.Hit, hpVal); + } + + if (mpVal != 0) + { + ChangeMP(mpVal); + BroadcastDamageIndicator(DamageType.Hit, mpVal); + } + } break; case 2: //MysteryWater if (UnlockCurse) diff --git a/Shared/Data/ItemData.cs b/Shared/Data/ItemData.cs index 3c93cfa5a..65207f635 100644 --- a/Shared/Data/ItemData.cs +++ b/Shared/Data/ItemData.cs @@ -2,6 +2,15 @@ public class ItemInfo { + private static readonly object __hpmpRngLock = new object(); + private static readonly System.Random __hpmpRng = new System.Random(); + private static int __NextIntInclusive(int minInclusive, int maxInclusive) + { + if (minInclusive > maxInclusive) (minInclusive, maxInclusive) = (maxInclusive, minInclusive); + lock (__hpmpRngLock) + return __hpmpRng.Next(minInclusive, maxInclusive + 1); + } + public int Index; public string Name = string.Empty; public ItemType Type; @@ -40,6 +49,13 @@ public class ItemInfo public Stats Stats; + // Optional tail marker for HP/MP range strings (backward compatible). + private const ushort HpMpTailMarker = 0xC0D1; + + // Raw range strings, e.g. "10~100". Intended for potions (recovery roll at use-time). + public string HPRollRaw { get; set; } = ""; + public string MPRollRaw { get; set; } = ""; + public bool IsConsumable { get { return Type == ItemType.Potion || Type == ItemType.Scroll || Type == ItemType.Food || Type == ItemType.Transform || Type == ItemType.Script || Type == ItemType.SealedHero; } @@ -196,6 +212,24 @@ public ItemInfo(BinaryReader reader, int version = int.MaxValue, int customVersi ToolTip = reader.ReadString(); } + // ---- Optional tail: HP/MP ranges ---- + // Older DBs will continue with the next ItemInfo.Index (int32). We "peek" a marker and rewind if not present. + var stream = reader.BaseStream; + if (stream.CanSeek && stream.Position + sizeof(ushort) <= stream.Length) + { + long pos = stream.Position; + ushort marker = reader.ReadUInt16(); + if (marker == HpMpTailMarker) + { + HPRollRaw = reader.ReadString(); + MPRollRaw = reader.ReadString(); + } + else + { + stream.Position = pos; + } + } + if (version < 70) //before db version 70 all specialitems had wedding rings disabled, after that it became a server option { if ((Type == ItemType.Ring) && (Unique != SpecialItemMode.None)) @@ -255,6 +289,36 @@ public void Save(BinaryWriter writer) if (ToolTip != null) writer.Write(ToolTip); + // ---- Append optional tail: HP/MP ranges ---- + writer.Write(HpMpTailMarker); + writer.Write(HPRollRaw ?? string.Empty); + writer.Write(MPRollRaw ?? string.Empty); + } + + private static bool TryParseRangeString(string s, out int min, out int max) + { + min = max = 0; + if (string.IsNullOrWhiteSpace(s)) return false; + var parts = s.Split('~'); + if (parts.Length != 2) return false; + if (!int.TryParse(parts[0].Trim(), out min)) return false; + if (!int.TryParse(parts[1].Trim(), out max)) return false; + if (min > max) (min, max) = (max, min); + return true; + } + + public int RollHP() + { + if (TryParseRangeString(HPRollRaw, out var min, out var max)) + return __NextIntInclusive(min, max); + return Stats[Stat.HP]; + } + + public int RollMP() + { + if (TryParseRangeString(MPRollRaw, out var min, out var max)) + return __NextIntInclusive(min, max); + return Stats[Stat.MP]; } public static ItemInfo FromText(string text) diff --git a/Shared/Language.cs b/Shared/Language.cs index 6b3423547..e500c45b5 100644 --- a/Shared/Language.cs +++ b/Shared/Language.cs @@ -874,7 +874,9 @@ public enum ClientTextKeys MaxAcPlusPercent, MaxMacPlusPercent, HealthRecoveryPlus, + HealthRecoveryPlusPercent, ManaRecoveryPlus, + ManaRecoveryPlusPercent, PoisonRecoveryPlus, AddsAgilityPlus, StrongPlus, @@ -3819,7 +3821,9 @@ public static class GameLanguage { nameof(ClientTextKeys.MaxAcPlusPercent), "Max AC + {0}%" }, { nameof(ClientTextKeys.MaxMacPlusPercent), "Max MAC + {0}%" }, { nameof(ClientTextKeys.HealthRecoveryPlus), "Health Recovery + {0}" }, + { nameof(ClientTextKeys.HealthRecoveryPlusPercent), "Health Recovery + {0}%" }, { nameof(ClientTextKeys.ManaRecoveryPlus), "Mana Recovery + {0}" }, + { nameof(ClientTextKeys.ManaRecoveryPlusPercent), "Mana Recovery + {0}%" }, { nameof(ClientTextKeys.PoisonRecoveryPlus), "Poison Recovery + {0}" }, { nameof(ClientTextKeys.AddsAgilityPlus), "Adds +{0} Agility" }, { nameof(ClientTextKeys.StrongPlus), "Strong + {0}" },