Supersedes #2376 (@jwvalentine). This is a reviewed, corrected, and scoped-down **Phase 1** of Joe Valentine's throwing implementation — his original commits are cherry-picked here with authorship preserved, plus a fix/scoping pass. Phase 1 lands only the **core gargoyle Throwing skill**; the incomplete content is excised for follow-up PRs (see below). ## What's included (core skill) - `BaseThrown` combat mechanics on top of the existing skeleton: close-quarters penalty, below-min-range penalty, shield penalty, STR-scaled range, overthrow damage penalty. - Base weapons: **Boomerang, Cyclone, SoulGlaive** (gargoyle-only), + blacksmith crafting (SA-gated). - Two symmetric hooks on `BaseWeapon` (`ModifyHitChance`, new `ModifyDamage`) that are inert no-ops for every other weapon. ## Fixes over the original - **Overthrow damage**: was dead code (the swing gate already guarantees you're within `MaxRange`, so the old `ComputeDamage` check never fired). Reimplemented as `finalDamage × 0.53` applied *after* all offensive bonuses via a new `ModifyDamage` hook, firing at the outer range ring. - **`DefMaxRange`**: clamped to `[MinThrowRange, MaxThrowRange]` (uncapped before → e.g. range 13 at 200 Str) and guarded against a latent divide-by-zero. - **Close-quarters mitigation** now uses `RawDex` (matches ServUO/OSI; deterministic under stat mods). - **Return-throw timer** guarded against a deleted/unmapped thrower/target. - Reverted the `MovingShot` change (it rebalanced archery — belongs in a separate PR). - Kept only complex-logic tests (hit-chance/range/damage math); dropped property-value assertions. ## Excised for follow-up PRs (Phase 2/3) 7 named artifacts, the Into-the-Void quest + Agralem, GargishOutcast, the Bladeweaver vendor, and the SA loot tables — these were unwired/non-functional (loot never triggered, quest/creature never spawned) and will return properly wired. `StormCaller` also needs its missing Battle Lust, and the quest its correct void-creature target. ## Verification - Build: 0 warnings / 0 errors. - `UOContent.Tests`: **501/501** passing (the `ModifyDamage` hook causes zero regressions across all weapons). - Full whole-branch review completed: no must-fix defects.
172 lines
5.6 KiB
C#
172 lines
5.6 KiB
C#
using System;
|
|
using ModernUO.Serialization;
|
|
|
|
namespace Server.Items;
|
|
|
|
[SerializationGenerator(0)]
|
|
public abstract partial class BaseThrown : BaseRanged
|
|
{
|
|
public BaseThrown(int itemID) : base(itemID)
|
|
{
|
|
}
|
|
|
|
public abstract int MinThrowRange { get; }
|
|
|
|
public virtual int MaxThrowRange => MinThrowRange + 3;
|
|
|
|
// Dynamic max range scaled by attacker Strength, clamped to the weapon's throw band.
|
|
// At StrReq the effective range equals MinThrowRange; at 140 Str it reaches MaxThrowRange.
|
|
public override int DefMaxRange
|
|
{
|
|
get
|
|
{
|
|
if (Parent is not Mobile attacker)
|
|
{
|
|
return MaxThrowRange;
|
|
}
|
|
|
|
var divisor = (140 - AosStrengthReq) / 3;
|
|
if (divisor <= 0)
|
|
{
|
|
return MaxThrowRange;
|
|
}
|
|
|
|
// Scale up from MinThrowRange so the base matches the clamp floor even if a
|
|
// subclass overrides MaxThrowRange to something other than MinThrowRange + 3.
|
|
var scaled = MinThrowRange + (attacker.Str - AosStrengthReq) / divisor;
|
|
return Math.Clamp(scaled, MinThrowRange, MaxThrowRange);
|
|
}
|
|
}
|
|
|
|
public override int EffectID => ItemID;
|
|
|
|
// Throwing weapons require no ammo — the weapon itself is the projectile.
|
|
public override Type AmmoType => null;
|
|
public override Item Ammo => null;
|
|
|
|
public override int DefHitSound => 0x5D3;
|
|
public override int DefMissSound => 0x5D4;
|
|
|
|
public override SkillName DefSkill => SkillName.Throwing;
|
|
public override SkillName AccuracySkill => SkillName.Throwing;
|
|
|
|
public override WeaponAnimation DefAnimation => WeaponAnimation.Throwing;
|
|
|
|
// Throwing-specific hit chance modifiers (applied via BaseWeapon.ModifyHitChance hook).
|
|
protected override double ModifyHitChance(Mobile attacker, Mobile defender, double chance)
|
|
{
|
|
// Use Chebyshev distance — consistent with all UO range mechanics.
|
|
var distance = Math.Max(
|
|
Math.Abs(attacker.X - defender.X),
|
|
Math.Abs(attacker.Y - defender.Y)
|
|
);
|
|
|
|
if (distance <= 1)
|
|
{
|
|
// Close-quarters penalty: up to -12%, mitigated by (Throwing + RawDex) / 20.
|
|
// At 240 combined (120 skill + 120 RawDex) the penalty is fully mitigated.
|
|
var throwSkill = attacker.Skills[SkillName.Throwing].Value;
|
|
var mitigation = Math.Min(12.0, (throwSkill + attacker.RawDex) / 20.0);
|
|
chance -= (12.0 - mitigation) / 100.0;
|
|
}
|
|
else if (distance < MinThrowRange)
|
|
{
|
|
// Below minimum throw range (but not melee): flat -12% hit chance.
|
|
chance -= 0.12;
|
|
}
|
|
|
|
// Shield penalty: equipping a shield while throwing reduces hit chance.
|
|
// Penalty = 1200 / Parry (capped at 90%), applied multiplicatively.
|
|
// High Parrying skill reduces the penalty significantly.
|
|
if (attacker.FindItemOnLayer<BaseShield>(Layer.TwoHanded) != null)
|
|
{
|
|
var parry = attacker.Skills[SkillName.Parry].Value;
|
|
var penalty = parry > 0.0 ? 1200.0 / parry : 90.0;
|
|
chance *= 1.0 - Math.Min(90.0, penalty) / 100.0;
|
|
}
|
|
|
|
return chance;
|
|
}
|
|
|
|
// Overthrow: a throw that reaches the edge of its (STR-scaled) range lands with 47% less
|
|
// damage, applied on top of all offensive bonuses. MaxRange is the dynamic DefMaxRange.
|
|
protected override int ModifyDamage(Mobile attacker, Mobile defender, int damage)
|
|
{
|
|
if (!attacker.InRange(defender.Location, MaxRange - 1))
|
|
{
|
|
damage = damage * 53 / 100;
|
|
}
|
|
|
|
return damage;
|
|
}
|
|
|
|
public override bool OnFired(Mobile attacker, Mobile defender)
|
|
{
|
|
if (!attacker.InRange(defender, 1))
|
|
{
|
|
attacker.MovingEffect(defender, EffectID, 18, 1, false, false, Hue, 0);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public override void OnHit(Mobile attacker, Mobile defender, double damageBonus = 1)
|
|
{
|
|
if (WeaponAbility.GetCurrentAbility(attacker) is not MysticArc)
|
|
{
|
|
var location = new WorldLocation(defender.Location, attacker.Map);
|
|
Timer.StartTimer(TimeSpan.FromSeconds(0.3), () => Return(attacker, defender, location));
|
|
}
|
|
|
|
base.OnHit(attacker, defender, damageBonus);
|
|
}
|
|
|
|
public override void OnMiss(Mobile attacker, Mobile defender)
|
|
{
|
|
if (WeaponAbility.GetCurrentAbility(attacker) is not MysticArc)
|
|
{
|
|
var location = new WorldLocation(defender.Location, attacker.Map);
|
|
Timer.StartTimer(TimeSpan.FromSeconds(0.3), () => Return(attacker, defender, location));
|
|
}
|
|
|
|
base.OnMiss(attacker, defender);
|
|
}
|
|
|
|
public virtual void Return(Mobile thrower, Mobile target, WorldLocation worldLocation)
|
|
{
|
|
if (thrower?.Deleted != false || thrower.Map == null || thrower.Map == Map.Internal)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (target?.Deleted == false)
|
|
{
|
|
target.MovingEffect(thrower, EffectID, 18, 1, false, false, Hue, 0);
|
|
}
|
|
else
|
|
{
|
|
Effects.SendMovingParticles(
|
|
new Entity(Serial.Zero, worldLocation.Location, worldLocation.Map),
|
|
thrower,
|
|
ItemID,
|
|
18,
|
|
0,
|
|
false,
|
|
false,
|
|
Hue,
|
|
0,
|
|
9502,
|
|
1,
|
|
0,
|
|
(EffectLayer)255,
|
|
0x100
|
|
);
|
|
}
|
|
}
|
|
|
|
public override void GetProperties(IPropertyList list)
|
|
{
|
|
base.GetProperties(list);
|
|
list.Add(1149791, MinThrowRange); // Min Throw Range: ~1_val~
|
|
}
|
|
}
|