Одержимость: прицел-перекрестие с покраснением, кнопки E/F, лут мешков и багажник волка, медведь на дыбы

- Прицел всех одержимых - всегда перекрестие (свой guiDrawCrosshair: точка бралась из проверки
  камеры от 3-го лица у тела); краснеет на враге: у стервятника - цель рывка, у наземных - враг
  на луче прицела до 30 м, не за стеной
- Падаль: отхил вдвое меньше (EatHealFrac 0.125); ест на F стервятник, медведь, собака, волк и
  одержимые зомби
- E - действие: стервятник - клич (был F), крыса/волк - обыск, медведь - на дыбы (враги до 3.5 м
  сбиты с ног и отброшены, откат 6 с)
- Рёв медведя: надписи «на медведя идут N» и откат, мимо очереди подсказок
- Крыса и волк обыскивают мешки с зомби (EntityLootContainer), волк берёт в сумку
- Багажник волка: окно сумки как у браслета, без рюкзака; выброс - у волка
- Отзыв волка: мешок на месте и метка «Сумка волка» на карте, снимается с мешком
- Тексты на 13 языках

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-09 13:31:55 +03:00
co-authored by Claude Opus 5.5
parent 31aa99860c
commit e94e967b8e
7 changed files with 572 additions and 55 deletions
+59 -1
View File
@@ -10,7 +10,9 @@ namespace NecromancerTome
/// увести толпу от тела или базы. Та же провокация, что каждую секунду делает одержимость
/// (Possession.Taunt), только шире, без «видит ли» и на 20 с;
/// - удар лапой по блоку: ЛКМ по блоку под прицелом бьёт его на SmashDamage (дерево и двери за 2-3
/// удара, бетон дольше).
/// удара, бетон дольше);
/// - на дыбы на E (указание 09.10: «на E - действие»): враги ближе RearRange сбиты с ног на RearStun с
/// и отброшены от медведя - передышка в толпе. Откат RearCooldown.
/// </summary>
public static class BearControl
{
@@ -25,6 +27,13 @@ namespace NecromancerTome
public const float SmashReach = 2.6f;
public const float SkinFactor = 0.5f;
static float _nextRoar;
public const float RearRange = 3.5f;
public const float RearStun = 2f;
public const float RearCooldown = 6f;
/// <summary>Толчок от медведя, м за тик (как motion существ; гаснет сам).</summary>
public const float RearPush = 0.5f;
public const string RearSound = "bearattack";
static float _nextRear;
public static bool Is(EntityAlive _e)
{
@@ -36,6 +45,8 @@ namespace NecromancerTome
{
if (Time.time < _nextRoar)
{
// Рёв в толпе не отличить от удара (тест 09.10) - пишем, сколько ждать и сколько пошло на медведя.
Tip(string.Format(Localization.Get("necroBearRoarCooldown"), Mathf.CeilToInt(_nextRoar - Time.time)));
return;
}
_nextRoar = Time.time + RoarCooldown;
@@ -59,6 +70,53 @@ namespace NecromancerTome
n++;
}
Debug.Log("[NecromancerTome] BearControl: рёв - на медведя идут " + n);
Tip(string.Format(Localization.Get("necroBearRoarResult"), n));
}
/// <summary>Сразу, мимо очереди подсказок: очередь игры (XUiC_PopupToolTip) отсеивает только одинаковый текст,
/// и «через 7 с», «через 6 с»... при частых ПКМ копились бы и всплывали с опозданием.</summary>
static void Tip(string _text)
{
if (Possession.Player != null)
{
GameManager.ShowTooltip(Possession.Player, _text, true);
}
}
/// <summary>E: на дыбы - враги рядом падают (тот же приём, что у броска волка: BeginStun Prone) и
/// отлетают от медведя.</summary>
public static void RearUp(World _world, EntityAlive _bear)
{
if (Time.time < _nextRear)
{
Tip(string.Format(Localization.Get("necroBearRearCooldown"), Mathf.CeilToInt(_nextRear - Time.time)));
return;
}
_nextRear = Time.time + RearCooldown;
_bear.PlayOneShot(RearSound);
_bear.emodel?.avatarController?.StartAnimationAttack();
int n = 0;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, _bear) || (e.position - _bear.position).sqrMagnitude > RearRange * RearRange)
{
continue;
}
Vector3 away = e.position - _bear.position;
away.y = 0f;
away = away.sqrMagnitude > 0.01f ? away.normalized : _bear.GetForwardVector();
e.motion += away * RearPush + Vector3.up * 0.15f;
if (e.bodyDamage.CurrentStun == EnumEntityStunType.None && e.emodel?.avatarController != null)
{
e.emodel.avatarController.BeginStun(EnumEntityStunType.Prone, EnumBodyPartHit.Torso, Utils.EnumHitDirection.Back, false, 0.5f);
e.SetStun(EnumEntityStunType.Prone);
e.bodyDamage.StunDuration = RearStun;
}
n++;
}
Tip(string.Format(Localization.Get("necroBearRearResult"), n));
Debug.Log("[NecromancerTome] BearControl: на дыбы - сбиты с ног " + n);
}
/// <summary>ЛКМ: блок под прицелом в досягаемости лапы (не существо) - урон SmashDamage.</summary>
+5
View File
@@ -317,6 +317,11 @@ namespace NecromancerTome
public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
{
__state = null;
// В одержимости прицел красит Possession.AimTint по цели одержимого, а не хват тела.
if (Possession.Active)
{
return;
}
int hold = HandHolds.Current(__instance);
EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
// «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
+132 -27
View File
@@ -122,14 +122,14 @@ namespace NecromancerTome
/// <summary>Толчок вверх при взлёте с земли на пробел, м за тик.</summary>
public const float FlyTakeOff = 0.3f;
static bool _flySitting;
/// <summary>Питание падалью (указание 06.10): сидя у трупа, F - EatTime секунд клюёт его и лечится на
/// <summary>Питание падалью (указание 06.10; у медведя и собаки - 09.10): сидя или стоя у трупа, E - EatTime секунд клюёт его и лечится на
/// EatHealFrac здоровья. У трупа запас мяса - его полное здоровье при жизни; поедание отнимает
/// столько же, сколько лечит (и на полном здоровье тоже). Запас кончился - труп исчезает (указание
/// 06.10), вместе с содержимым, если это красный труп-добыча. Сдвинулся, взлетел, ударил - еда
/// прервана, вылеченное и съеденное остаются; к трупу можно вернуться.</summary>
public const float EatRange = 2.5f;
public const float EatTime = 3f;
public const float EatHealFrac = 0.25f;
public const float EatHealFrac = 0.125f; // было 0.25 - указание 09.10: «лечится слишком быстро, вдвое»
static EntityAlive _eatCorpse;
static float _eatLeft, _eatHeal, _eatBite;
static int _eatCount;
@@ -325,6 +325,7 @@ namespace NecromancerTome
}
Active = false;
RatLoot.CloseIfViewing();
WolfBag.CloseWindow();
RatPhase.Stop(GameManager.Instance?.World, Zombie, "выход из одержимости");
DogSense.Clear();
WolfControl.Clear();
@@ -588,20 +589,23 @@ namespace NecromancerTome
_nextAttack = Time.time + FlyAttackDelay;
_meleeLookUntil = Time.time + FlyAttackDelay;
}
// F - клич-приманка (указание 06.10; кнопка - 08.10, была ПКМ): крик крикуньи, только голосом
// паникующей курицы - зомби в ScreamRange идут на него; с воздуха уводит толпу от базы или от тела.
// F у игрока - фонарик, но его читает только выключенный PlayerMoveController.Update.
bool scream = input != null && !gui && (input.PermanentActions.ToggleFlashlight.WasPressed || input.ToggleFlashlight.WasPressed);
// E - клич-приманка (указание 06.10; кнопка - 08.10 F, была ПКМ; 09.10 - E: «на E действие, у стервятника крик»):
// крик крикуньи, только голосом паникующей курицы - зомби в ScreamRange идут на него; с воздуха уводит
// толпу от базы или от тела. detonate - это и есть нажатие E.
bool scream = detonate;
if (scream)
{
Scream(world, z);
}
// ПКМ (держать) - «зоркий глаз» (указание 08.10: «приближение как у прицела»), в PlaceCamera.
_flyZoomHeld = rangedHeld;
// E - питаться падалью (указание 06.10, кнопка - 08.10: «пусть ест по кнопке E» - у игрока E это
// взаимодействие, лут; было F). detonate - это и есть нажатие E.
bool eat = detonate;
Eat(world, z, eat, attack || scream);
// F - питаться падалью (указание 06.10; кнопка 08.10 - E, 09.10 - F у всех одержимых питомцев).
// F у игрока - фонарик, но его читает только выключенный PlayerMoveController.Update.
bool eat = FKey(input, gui);
// Есть можно сидя или просто остановившись рядом (зависнув над падалью): без ввода и почти без скорости.
bool still = _flySitting || _flyMove.sqrMagnitude < 0.01f && !_flyUp &&
new Vector2(z.motion.x, z.motion.z).sqrMagnitude < FlySitSpeed * FlySitSpeed;
Eat(world, z, eat, attack || scream || _flyMove.sqrMagnitude > 0.01f || _flyUp || !_flySitting, still);
if (_meleeLookUntil > 0f && Time.time >= _meleeLookUntil)
{
_meleeLookUntil = 0f;
@@ -643,16 +647,29 @@ namespace NecromancerTome
{
// ПКМ - бросок на цель, след крови раненых (решено 09.10).
WolfControl.Tick(world, _player, z, ranged, ref _bodyYaw);
// E: у тела - выгрузить сумку в рюкзак, у контейнера - лутать в сумку (3 ячейки).
if (!(detonate && WolfBag.TryUnload(_player, z)))
// E: на контейнер или мешок с зомби - лутать в сумку (3 ячейки); у тела - выгрузить сумку в рюкзак;
// иначе - «багажник», окно сумки (указание 09.10).
bool looted = RatLoot.Tick(world, _player, z, detonate, true);
if (detonate && !looted && !RatLoot.Viewing && !WolfBag.TryUnload(_player, z))
{
RatLoot.Tick(world, _player, z, detonate, true);
WolfBag.OpenWindow(_player, z);
}
}
if (ranged && IsPet && BearControl.Is(z))
{
BearControl.Roar(world, z); // ПКМ - рёв-провокация (решено 09.10)
}
if (detonate && IsPet && BearControl.Is(z))
{
BearControl.RearUp(world, z); // E - на дыбы, враги рядом падают (указание 09.10)
}
if (EatsCarrion(z))
{
// F - есть падаль, как стервятник (указание 09.10: «поедание падали на F одержимым питомцам, на E -
// действие»; и одержимым зомби - их ИИ в игре и так ест трупы), стоя на месте рядом с трупом.
Eat(world, z, FKey(input, gui), moving || jump || attack || ranged, !moving && z.onGround);
}
AimTint(GroundAimTarget(world, z) != null);
if (IsPet && DogSense.Is(z))
{
// Нюх (указание 09.10): все существа рядом с собакой - на компасе и карте.
@@ -758,11 +775,20 @@ namespace NecromancerTome
}
}
static void Eat(World _world, EntityAlive _v, bool _pressed, bool _acted)
/// <summary>Едят падаль на F, кроме стервятника (указание 09.10): чужие одержимые зомби и питомцы медведь,
/// собака, волк. Крыса - дух, не ест.</summary>
static bool EatsCarrion(EntityAlive _z) => !IsPet || BearControl.Is(_z) || DogSense.Is(_z) || WolfControl.Is(_z);
/// <summary>F на клавиатуре или геймпаде (у игрока - фонарик).</summary>
static bool FKey(PlayerActionsLocal _input, bool _gui) =>
_input != null && !_gui && (_input.PermanentActions.ToggleFlashlight.WasPressed || _input.ToggleFlashlight.WasPressed);
/// <summary>_moved - сдвинулся или что-то сделал (еда прерывается), _still - стоит/сидит, можно начать.</summary>
static void Eat(World _world, EntityAlive _v, bool _pressed, bool _moved, bool _still)
{
if (_eating)
{
bool moved = _flyMove.sqrMagnitude > 0.01f || _flyUp || _acted || !_flySitting;
bool moved = _moved;
bool gone = _eatCorpse != null ? _eatCorpse.IsMarkedForUnload() : _world.GetBlock(_eatBlock).type != _eatBlockType;
if (moved || gone)
{
@@ -822,17 +848,14 @@ namespace NecromancerTome
{
return;
}
// Есть можно сидя или просто остановившись рядом (зависнув над падалью): без ввода и почти без скорости.
bool still = _flySitting || _flyMove.sqrMagnitude < 0.01f && !_flyUp &&
new Vector2(_v.motion.x, _v.motion.z).sqrMagnitude < FlySitSpeed * FlySitSpeed;
if (!still || !FindCarrion(_world, _v))
if (!_still || !FindCarrion(_world, _v))
{
GameManager.ShowTooltip(_player, Localization.Get("necroPossessNoCarrion"));
Debug.Log("[NecromancerTome] Possession: падали нет" + (still ? "" : " - стервятник не остановился"));
GameManager.ShowTooltip(_player, Localization.Get(Flying ? "necroPossessNoCarrion" : "necroPossessNoCarrionGround"));
Debug.Log("[NecromancerTome] Possession: падали нет" + (_still ? "" : " - " + _v.EntityName + " не остановился"));
return;
}
_eating = true;
if (!_flySitting)
if (Flying && !_flySitting)
{
// Сел за еду сразу, не дожидаясь тика полёта: иначе проверка «не сидит» тут же прервала бы еду.
_flySitting = true;
@@ -845,7 +868,7 @@ namespace NecromancerTome
_eatCount = 0;
_v.SetLookPosition(_eatCorpse != null ? _eatCorpse.getChestPosition() : _eatBlock.ToVector3() + new Vector3(0.5f, 0.3f, 0.5f));
_v.IsEating = true; // как у зомби: анимация поедания и синхронизация по сети
Debug.Log("[NecromancerTome] Possession: стервятник ест " + EatName() + ", здоровье " + _v.Health + "/" + _v.GetMaxHealth() +
Debug.Log("[NecromancerTome] Possession: " + _v.EntityName + " ест " + EatName() + ", здоровье " + _v.Health + "/" + _v.GetMaxHealth() +
", мяса у падали " + Mathf.CeilToInt(EatMeat(0f)));
}
@@ -1011,7 +1034,6 @@ namespace NecromancerTome
colors[_tintIndex] = _tintSaved;
_tintIndex = -1;
}
Debug.Log("[NecromancerTome] Possession: прицел " + (_on ? "на цели - красный" : "без цели"));
}
static readonly Color AimOnTarget = new Color(1f, 0.15f, 0.1f, 1f);
@@ -1219,6 +1241,47 @@ namespace NecromancerTome
static EntityAlive _aimMissNear;
static float _aimMissDist, _aimMissOff;
/// <summary>Враг под прицелом наземного одержимого (указание 09.10: прицел краснеет на противнике у
/// всех одержимых): тело врага - отрезок от ног до головы - не дальше GroundAimRadius от луча
/// камеры и не дальше GroundAimRange от одержимого, и не за стеной - не дальше первого попадания
/// луча (AimPoint).</summary>
static EntityAlive GroundAimTarget(World _world, EntityAlive _z)
{
Transform cam = _player?.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 from = cam.position + Origin.position;
Vector3 dir = cam.forward;
float wall = Vector3.Dot(AimPoint() - from, dir) + 0.5f;
EntityAlive best = null;
float bestAlong = float.MaxValue;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, _z) || (e.position - _z.position).sqrMagnitude > GroundAimRange * GroundAimRange)
{
continue;
}
Vector3 feet = e.position, head = e.getHeadPosition();
for (int k = 0; k <= 4; k++)
{
Vector3 p = Vector3.Lerp(feet, head, k / 4f);
float a = Vector3.Dot(p - from, dir);
if (a > 0f && a < wall && a < bestAlong && Vector3.Cross(p - from, dir).magnitude < GroundAimRadius)
{
best = e;
bestAlong = a;
}
}
}
return best;
}
public const float GroundAimRange = 30f; // зрение зомби, как TauntRange
public const float GroundAimRadius = 0.6f;
/// <summary>Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу (как у змеи).</summary>
static void CheckBody(World _world, EntityAlive _z, float _dt)
{
@@ -1288,11 +1351,11 @@ namespace NecromancerTome
}
if (Flying)
{
text += ", " + Localization.Get("necroPossessHintZoom") + ", " + Localization.Get("necroPossessHintScreamF");
text += ", " + Localization.Get("necroPossessHintZoom") + ", " + Localization.Get("necroPossessHintScreamE");
}
if (Flying)
{
text += ", " + Localization.Get("necroPossessHintEat");
text += ", " + Localization.Get("necroPossessHintEatF");
}
if (IsPet && RatModel.Is(_z))
{
@@ -1309,9 +1372,14 @@ namespace NecromancerTome
}
if (IsPet && BearControl.Is(_z))
{
text += ", " + Localization.Get("necroPossessHintRoar") + ", " + Localization.Get("necroPossessHintSmash") + ", " +
text += ", " + Localization.Get("necroPossessHintRoar") + ", " + Localization.Get("necroPossessHintRear") + ", " +
Localization.Get("necroPossessHintSmash") + ", " +
Localization.Get("necroPossessHintSkin");
}
if (EatsCarrion(_z))
{
text += ", " + Localization.Get("necroPossessHintEatGroundF");
}
if (_z.jumpMaxDistance >= 5f)
{
text += ", " + Localization.Get("necroPossessHintLeap");
@@ -1765,6 +1833,43 @@ namespace NecromancerTome
}
}
/// <summary>Прицел одержимого - всегда перекрестие (указание 09.10: «то перекрестие, то точка»). Вид прицела
/// игра берёт у предмета в руке тела, а у книги призыва и пустой руки он зависит от проверки камеры от
/// третьего лица (ItemAction.GetCrosshairType - EntityPlayerLocal.CharacterCameraAngleValid): камера дальше
/// 20° от того, куда повёрнуто тело, - прицела нет, и игра рисует точку. В одержимости камера - глаза
/// одержимого, тело стоит, так что вид менялся от поворота мыши. Рисуем сами то же, что ветка Plus
/// guiDrawCrosshair; цвет - из таблицы игры, его красит Possession.AimTint.</summary>
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))]
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_Possession
{
public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen)
{
if (!Possession.Active || __instance != Possession.Player)
{
return true;
}
if (!_guiInGame.showCrosshair || Event.current.type != EventType.Repaint || __instance.IsDead() ||
!GamePrefs.GetBool(EnumGamePrefs.OptionsCrosshairEnabled) || bModalWindowOpen || _guiInGame.CrosshairTexture == null)
{
return false;
}
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
{
i = 0;
}
Color c = EntityPlayerLocal.crosshairColors[i];
Vector2 center = __instance.GetCrosshairPosition2D();
center.y = Screen.height - center.y;
float size = Screen.height * 0.059f * GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairScale);
Color was = GUI.color;
GUI.color = new Color(c.r, c.g, c.b, GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairOpacity));
GUI.DrawTexture(new Rect(center.x - size / 2f, center.y - size / 2f, size, size), _guiInGame.CrosshairTexture, ScaleMode.StretchToFill);
GUI.color = was;
return false;
}
}
/// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_Possession
+176 -16
View File
@@ -17,6 +17,11 @@ namespace NecromancerTome
/// переполнены»): звук ui_denied и xuiInventoryFullForPickup;
/// - окно не закрывается из-за дальности до ТЕЛА: XUiC_LootWindow.Update меряет её от персонажа.
/// Подсказку «E: обыскать» рисует управление игрока, а оно в одержимости выключено - выводим сами.
///
/// МЕШКИ (указание 09.10: «лутать жёлтые мешочки, и синие, и красные, падающие с зомби»). Мешок - сущность
/// EntityLootContainer, открывается той же цепочкой замка (LockRequestLocal -> Entity.OnLockResponseLocal), но
/// окно другое - XUiC_BagStorageWindowGroup; для него те же отличия: рюкзак закрыт, ячейки заперты, дальность
/// от крысы. Рюкзак погибшего игрока (EntityBackpack) - не мешок, его не трогаем.
/// </summary>
public static class RatLoot
{
@@ -29,6 +34,41 @@ namespace NecromancerTome
static float _spareUntil;
/// <summary>Досягаемость контейнера от крысы, м.</summary>
const float Reach = 2.5f;
/// <summary>Мешок - не дальше стольких градусов от луча камеры (мешок мал, точно навести трудно).</summary>
const float BagAimDeg = 25f;
/// <summary>Открытый на просмотр мешок - для щелчков по ячейкам и «Взять всё».</summary>
static EntityLootContainer _viewBag;
/// <summary>Кто осматривает - для лога (у волка тот же обыск).</summary>
static string Who => Possession.Zombie != null && WolfControl.Is(Possession.Zombie) ? "волк" : "крыса";
/// <summary>Мешок перед крысой: ближе Reach к ней и ближе всех к лучу камеры, не дальше BagAimDeg.</summary>
public static EntityLootContainer FindBag(World _world, EntityPlayerLocal _player, EntityAlive _rat)
{
Transform cam = _player.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 from = cam.position + Origin.position;
EntityLootContainer best = null;
float bestDeg = BagAimDeg;
for (int i = 0; i < _world.Entities.list.Count; i++)
{
if (!(_world.Entities.list[i] is EntityLootContainer bag) || bag.IsMarkedForUnload() || bag.bag == null ||
(bag.position - _rat.position).sqrMagnitude > Reach * Reach)
{
continue;
}
float deg = Vector3.Angle(cam.forward, bag.position + Vector3.up * 0.2f - from);
if (deg < bestDeg)
{
bestDeg = deg;
best = bag;
}
}
return best;
}
/// <summary>Контейнер перед крысой по лучу камеры: блок с хранилищем ближе Reach к крысе; иначе null.</summary>
public static TEFeatureStorage Find(World _world, EntityPlayerLocal _player, EntityAlive _rat, out Vector3i _pos, out BlockValue _bv)
@@ -76,21 +116,39 @@ namespace NecromancerTome
return st;
}
/// <summary>Каждый кадр одержимости крысой: подсказка и E - открыть.</summary>
public static void Tick(World _world, EntityPlayerLocal _player, EntityAlive _rat, bool _activate, bool _take = false)
/// <summary>Каждый кадр одержимости крысой: подсказка и E - открыть. true - нажатие E ушло на контейнер или
/// мешок (волку тогда не открывать свою сумку).</summary>
public static bool Tick(World _world, EntityPlayerLocal _player, EntityAlive _rat, bool _activate, bool _take = false)
{
LocalPlayerUI ui = _player.PlayerUI;
if (ui == null || Viewing || ui.windowManager.IsModalWindowOpen())
{
return;
}
TEFeatureStorage st = Find(_world, _player, _rat, out Vector3i pos, out BlockValue bv);
if (st == null)
{
return;
return false;
}
PlayerActionsLocal input = _player.playerInput;
string key = input.Activate.GetBindingXuiMarkupString() + input.PermanentActions.Activate.GetBindingXuiMarkupString();
TEFeatureStorage st = Find(_world, _player, _rat, out Vector3i pos, out BlockValue bv);
if (st == null)
{
EntityLootContainer bag = FindBag(_world, _player, _rat);
if (bag == null)
{
return false;
}
XUiC_InteractionPrompt.SetText(ui, string.Format(Localization.Get("necroPossessSearchBag"), key, bag.LocalizedEntityName));
if (!_activate)
{
return false;
}
Viewing = true;
Taking = _take;
_viewBag = bag;
XUiC_InteractionPrompt.SetText(ui, null);
_lockRequest = _lockRequest ?? AccessTools.Method(typeof(LockManager), nameof(LockManager.LockRequestLocal), new[] { typeof(ILockTarget), typeof(ushort) });
_lockRequest.Invoke(LockManager.Instance, new object[] { bag, (ushort)0 });
Debug.Log("[NecromancerTome] RatLoot: " + Who + " осматривает мешок " + EntityClass.GetEntityClassName(bag.entityClass) + " (" + bag.entityId + ")");
return true;
}
string text = st.GetActivationText(_world, pos, bv, _rat, key, bv.Block.GetLocalizedBlockName());
if (!string.IsNullOrEmpty(text))
{
@@ -98,7 +156,7 @@ namespace NecromancerTome
}
if (!_activate)
{
return;
return false;
}
bool locked = st.lockpickFeature != null && st.lockpickFeature.NeedsLockpicking() ||
st.lockFeature != null && st.lockFeature.IsLocked() && !st.lockFeature.IsUserAllowed(Platform.PlatformManager.InternalLocalUserIdentifier) ||
@@ -106,7 +164,7 @@ namespace NecromancerTome
if (locked)
{
Audio.Manager.BroadcastPlayByLocalPlayer(pos.ToVector3() + Vector3.one * 0.5f, "Misc/locked");
return;
return true;
}
Viewing = true;
Taking = _take;
@@ -116,7 +174,8 @@ namespace NecromancerTome
// Через отражение: у LockRequestLocal есть перегрузка с ReadOnlySpan, которой нет в справочных сборках проекта.
_lockRequest = _lockRequest ?? AccessTools.Method(typeof(LockManager), nameof(LockManager.LockRequestLocal), new[] { typeof(ILockTarget), typeof(ushort) });
_lockRequest.Invoke(LockManager.Instance, new object[] { st, (ushort)0 });
Debug.Log("[NecromancerTome] RatLoot: крыса осматривает " + bv.Block.GetBlockName() + " " + pos);
Debug.Log("[NecromancerTome] RatLoot: " + Who + " осматривает " + bv.Block.GetBlockName() + " " + pos);
return true;
}
static System.Reflection.MethodInfo _lockRequest;
@@ -141,12 +200,17 @@ namespace NecromancerTome
LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
ui?.windowManager.CloseAllOpenModalWindows();
Viewing = false;
_viewBag = null;
_spareUntil = Time.time + 5f;
}
static void LockSlots(XUiC_LootWindow _w, bool _lock)
{
XUiC_LootContainer grid = _w?.lootContainer;
LockSlots(_w?.lootContainer, _lock);
}
static void LockSlots(XUiC_ItemStackGrid grid, bool _lock)
{
if (grid?.itemControllers == null)
{
return;
@@ -263,6 +327,90 @@ namespace NecromancerTome
}
}
/// <summary>Окно мешка открылось на просмотр - рюкзак закрыть, ячейки запереть (как у ящика).</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnOpen))]
public static class Patch_Bag_OnOpen
{
public static void Postfix(XUiC_BagStorageWindowGroup __instance)
{
if (!Viewing || _viewBag == null)
{
return;
}
__instance.xui.playerUI.windowManager.Close("backpack");
LockSlots(__instance.containerWindow, true);
}
}
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnClose))]
public static class Patch_Bag_OnClose
{
public static void Prefix(XUiC_BagStorageWindowGroup __instance)
{
LockSlots(__instance.containerWindow, false); // всегда: запертые ячейки не должны достаться персонажу
if (Viewing && _viewBag != null)
{
Viewing = false;
_viewBag = null;
}
}
}
/// <summary>Таймер обыска мешка прерван - просмотра нет.</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.CancelOpen))]
public static class Patch_Bag_CancelOpen
{
public static void Postfix()
{
if (Viewing && _viewBag != null)
{
Viewing = false;
_viewBag = null;
}
}
}
/// <summary>Замок мешка не дан (занят другим) - просмотра нет.</summary>
[HarmonyPatch(typeof(Entity), nameof(Entity.OnLockResponseLocal))]
public static class Patch_Entity_OnLockResponseLocal
{
public static void Postfix(Entity __instance, bool __result)
{
if (!__result && Viewing && _viewBag != null && __instance == _viewBag)
{
Viewing = false;
_viewBag = null;
}
}
}
/// <summary>Дальность окна мешка - от крысы: проверка окна меряет её до entityPlayer, поэтому на время Update
/// персонаж «стоит» на месте крысы, как у окна ящика.</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.Update))]
public static class Patch_Bag_Update
{
public static void Prefix(XUiC_BagStorageWindowGroup __instance, out Vector3? __state)
{
__state = null;
EntityPlayerLocal p = __instance.xui.playerUI.entityPlayer;
EntityAlive rat = Possession.Zombie;
if (!Viewing || _viewBag == null || p == null || rat == null)
{
return;
}
__state = p.position;
p.position = rat.position;
}
public static void Finalizer(XUiC_BagStorageWindowGroup __instance, Vector3? __state)
{
if (__state.HasValue)
{
__instance.xui.playerUI.entityPlayer.position = __state.Value;
}
}
}
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveAll))]
public static class Patch_MoveAll
{
@@ -277,6 +425,10 @@ namespace NecromancerTome
{
WolfBag.TakeAll(te);
}
else if (Taking && _viewBag != null)
{
WolfBag.TakeAll(_viewBag);
}
else
{
Deny();
@@ -300,21 +452,29 @@ namespace NecromancerTome
{
public static void Postfix(XUiC_ItemStack __instance)
{
if (!Viewing || !__instance.isOver || __instance.ItemStack == null || __instance.ItemStack.IsEmpty() ||
__instance.GetParentByType<XUiC_LootContainer>() == null)
if (!Viewing || !__instance.isOver || __instance.ItemStack == null || __instance.ItemStack.IsEmpty())
{
return;
}
// Ячейка контейнера-блока (XUiC_LootContainer) или мешка (XUiC_BagContainer), не рюкзака.
XUiC_ItemStackGrid grid = (XUiC_ItemStackGrid)__instance.GetParentByType<XUiC_LootContainer>() ?? __instance.GetParentByType<XUiC_BagContainer>();
if (grid == null)
{
return;
}
CursorControllerAbs cur = __instance.xui.playerUI.CursorController;
if (cur.GetMouseButtonDown(UICamera.MouseButton.LeftButton) || cur.GetMouseButtonDown(UICamera.MouseButton.RightButton))
{
XUiC_LootContainer grid = __instance.GetParentByType<XUiC_LootContainer>();
TEFeatureStorage te = __instance.GetParentByType<XUiC_LootWindow>()?.te;
int idx = grid?.itemControllers != null ? System.Array.IndexOf(grid.itemControllers, __instance) : -1;
int idx = grid.itemControllers != null ? System.Array.IndexOf(grid.itemControllers, __instance) : -1;
if (Taking && te != null && idx >= 0)
{
WolfBag.TakeFrom(te, idx);
}
else if (Taking && _viewBag != null && grid is XUiC_BagContainer && idx >= 0)
{
WolfBag.TakeFrom(_viewBag, idx);
}
else
{
Deny();
+187 -8
View File
@@ -11,7 +11,12 @@ namespace NecromancerTome
/// но щелчок по предмету (и «Взять всё») кладёт его в сумку волка; места нет - «Сумка полна»;
/// - выгрузка - принести к телу: рядом с телом E перекладывает сумку в рюкзак персонажа, что не
/// влезло - мешком на землю рядом;
/// - волк погиб или отозван - сумка выпадает мешком на месте.
/// - волк погиб или отозван - сумка выпадает мешком на месте; при отзыве (указание 09.10) на карте метка
/// «Сумка волка», чтобы за ним прийти; мешок подобран или истлел - метка снята (Patch_removeBackpack);
/// - «багажник» (указание 09.10: «почти как у пространственного хранилища»): E не на контейнер и не у тела -
/// окно сумки, то же ванильное окно, что у браслета (XUiC_BagStorageWindowGroup, шаблон roboticDrone), но
/// без рюкзака персонажа: внутри перекладывать, вытащить за окно - выбросить у волка (Patch_GetDropPosition);
/// - мешки с зомби волк лутает так же, как ящики (RatLoot, TakeFrom(EntityLootContainer)).
///
/// ХРАНЕНИЕ. Сумку сущности игра не сохраняет (Entity.bag нет в Entity.Write), поэтому она своя.
/// Упаковка - та же, что у пространственного хранилища (SpatialVaultPersistence.BuildPayload/ParsePayload,
@@ -25,6 +30,11 @@ namespace NecromancerTome
public const int Slots = 3;
const string FileName = "NecromancerTome_wolfbag.bin";
public const float UnloadRange = 3f;
/// <summary>Метка выпавшей сумки: имя - ключ локализации, по нему и находим свою метку среди меток игрока.</summary>
public const string WaypointName = "necroWolfBagWaypoint";
public const string WaypointIcon = "ui_game_symbol_backpack";
/// <summary>Открыто окно сумки («багажник»).</summary>
public static bool WindowOpen;
static Bag _bag;
static string _dir;
@@ -112,11 +122,41 @@ namespace NecromancerTome
/// в пустую ячейку; в контейнере остаётся ровно то, что не влезло.</summary>
public static bool TakeFrom(TEFeatureStorage _te, int _idx)
{
ItemStack src = _te.ItemGrid.GetItem(_idx);
if (src == null || src.IsEmpty())
ItemStack rest = Take(_te.ItemGrid.GetItem(_idx));
if (rest == null)
{
return false;
}
_te.UpdateSlot(_idx, rest);
_te.SetModified();
_te.Parent?.NotifyListeners(); // окно контейнера перерисует ячейку (как ванильная сортировка)
return true;
}
/// <summary>То же из мешка, упавшего с зомби. Окно мешка перерисуется по событию сумки (SetSlot).</summary>
public static bool TakeFrom(EntityLootContainer _src, int _idx)
{
if (_idx >= _src.bag.SlotCount)
{
return false;
}
ItemStack rest = Take(_src.bag.ItemGrid.items[_idx]);
if (rest == null)
{
return false;
}
_src.bag.SetSlot(_idx, rest);
_src.OnBagModified(); // на клиенте - отправить сумку мешка серверу, как делает окно
return true;
}
/// <summary>Переложить стопку в сумку; что осталось в источнике (пустая стопка, если всё), или null - не влезло.</summary>
static ItemStack Take(ItemStack src)
{
if (src == null || src.IsEmpty())
{
return null;
}
Bag b = Get();
ItemStack moving = src.Clone();
int before = moving.count;
@@ -130,17 +170,15 @@ namespace NecromancerTome
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
Show("necroWolfBagFull");
return false;
return null;
}
ItemStack rest = src.Clone();
rest.count = moving.count;
_te.UpdateSlot(_idx, rest.count > 0 ? rest : ItemStack.Empty.Clone());
_te.SetModified();
_te.Parent?.NotifyListeners(); // окно контейнера перерисует ячейку (как ванильная сортировка)
Save();
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
Show("necroWolfBagTook");
return true;
Debug.Log("[NecromancerTome] WolfBag: в сумку " + src.itemValue.ItemClass.GetItemName() + " x" + moved + " - " + Describe());
return rest.count > 0 ? rest : ItemStack.Empty.Clone();
}
/// <summary>«Взять всё» - по ячейкам, пока есть место.</summary>
@@ -161,6 +199,54 @@ namespace NecromancerTome
}
}
/// <summary>«Взять всё» из мешка - по ячейкам, пока есть место.</summary>
public static void TakeAll(EntityLootContainer _src)
{
bool any = false;
ItemStack[] slots = _src.bag.ItemGrid.items;
for (int i = 0; i < slots.Length; i++)
{
if (!slots[i].IsEmpty() && Get().CanTakeItem(slots[i]))
{
any |= TakeFrom(_src, i);
}
}
if (!any)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
Show("necroWolfBagFull");
}
}
/// <summary>«Багажник»: окно сумки, как у пространственного браслета (шаблон дрона), без рюкзака персонажа.
/// Перекладка внутри пишется сразу (Save на каждое изменение и на закрытие).</summary>
public static void OpenWindow(EntityPlayerLocal _player, EntityAlive _wolf)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
if (ui == null)
{
return;
}
WindowOpen = true;
XUiC_BagStorageWindowGroup.Open(ui.xui, _wolf, Get(), LootContainer.GetLootContainer("roboticDrone"),
Localization.Get("necroWolfBagWindowTitle"), Save, () =>
{
WindowOpen = false;
Save();
});
Debug.Log("[NecromancerTome] WolfBag: окно сумки - " + Describe());
}
/// <summary>Выход из одержимости при открытом окне сумки - закрыть его.</summary>
public static void CloseWindow()
{
if (WindowOpen)
{
LocalPlayerUI.GetUIForPrimaryPlayer()?.windowManager.CloseAllOpenModalWindows();
WindowOpen = false;
}
}
/// <summary>E рядом с телом: сумка - в рюкзак, что не влезло - мешком у тела.</summary>
public static bool TryUnload(EntityPlayerLocal _body, EntityAlive _wolf)
{
@@ -228,9 +314,61 @@ namespace NecromancerTome
b.Clear();
Save();
Drop(_wolf.position, items);
if (_why == "отозван")
{
AddWaypoint(_wolf.position);
}
Debug.Log("[NecromancerTome] WolfBag: волк " + _why + " - сумка выпала мешком (" + items.Count + ")");
}
/// <summary>Метка на карте и компасе - как игра ставит метку транспорта (XUiC_MapArea.RefreshVehiclePositionWaypoint),
/// но обычная, не «последнее место сущности»: номер мешка после перезахода другой. Метка - в списке меток игрока,
/// сохраняется игрой вместе с ним; удалить можно и вручную.</summary>
static void AddWaypoint(Vector3 _at)
{
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p?.Waypoints == null)
{
return;
}
Waypoint w = new Waypoint();
w.pos = World.worldToBlockPos(_at);
w.icon = WaypointIcon;
w.ownerId = Platform.PlatformManager.InternalLocalUserIdentifier;
w.name.Update(WaypointName, Platform.PlatformManager.MultiPlatform.User.PlatformUserId);
w.bUsingLocalizationId = true;
p.Waypoints.Collection.Add(w);
w.navObject = NavObjectManager.Instance.RegisterNavObject("waypoint", w.pos.ToVector3(), w.icon);
w.navObject.IsActive = false;
w.navObject.name = w.name.Text;
w.navObject.usingLocalizationId = true;
Debug.Log("[NecromancerTome] WolfBag: метка сумки на карте " + w.pos);
}
/// <summary>Мешок исчез (опустел или истлел) - снять метку сумки, стоящую рядом с ним.</summary>
public static void RemoveWaypointAt(Vector3 _at)
{
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p?.Waypoints == null)
{
return;
}
List<Waypoint> list = p.Waypoints.Collection.list;
for (int i = list.Count - 1; i >= 0; i--)
{
Waypoint w = list[i];
if (w.name.Text == WaypointName && (w.pos.ToVector3() - _at).sqrMagnitude < 3f * 3f)
{
p.Waypoints.Collection.Remove(w);
if (w.navObject != null)
{
NavObjectManager.Instance.UnRegisterNavObject(w.navObject);
}
Debug.Log("[NecromancerTome] WolfBag: мешок сумки подобран - метка снята " + w.pos);
}
}
}
static void Drop(Vector3 _at, List<ItemStack> _items)
{
GameManager.Instance.DropContentInLootContainerServer(-1, "DroppedLootContainer", _at + Vector3.up * 0.5f, _items.ToArray());
@@ -244,6 +382,47 @@ namespace NecromancerTome
}
}
/// <summary>Мешок исчез (пустой после обыска - «empty», или истлел - «old») - снять метку сумки волка рядом.</summary>
[HarmonyPatch(typeof(EntityContainerAbs), nameof(EntityContainerAbs.removeBackpack))]
public static class Patch_EntityContainerAbs_removeBackpack_WolfBag
{
public static void Postfix(EntityContainerAbs __instance)
{
if (__instance is EntityLootContainer)
{
WolfBag.RemoveWaypointAt(__instance.position);
}
}
}
/// <summary>«Багажник» без рюкзака персонажа: окно мешка открывает его само (OnOpen), закрываем сразу же.</summary>
[HarmonyPatch(typeof(XUiC_BagStorageWindowGroup), nameof(XUiC_BagStorageWindowGroup.OnOpen))]
public static class Patch_XUiC_BagStorageWindowGroup_OnOpen_WolfBag
{
public static void Postfix(XUiC_BagStorageWindowGroup __instance)
{
if (WolfBag.WindowOpen && Possession.Active)
{
__instance.xui.playerUI.windowManager.Close("backpack");
}
}
}
/// <summary>Предмет, вытащенный за окно сумки волка, падает у волка, а не у тела (XUiM_PlayerInventory.DropItem
/// берёт место у игрока - GetDropPosition).</summary>
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.GetDropPosition))]
public static class Patch_EntityPlayerLocal_GetDropPosition_WolfBag
{
public static void Postfix(EntityPlayerLocal __instance, ref Vector3 __result)
{
EntityAlive wolf = Possession.Zombie;
if (WolfBag.WindowOpen && Possession.Active && __instance == Possession.Player && wolf != null)
{
__result = wolf.position + wolf.GetForwardVector() * 0.8f + Vector3.up * 0.5f;
}
}
}
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.OnEntityDeath))]
public static class Patch_EntityAlive_OnEntityDeath_WolfBag
{