using System; using System.Collections.Generic; using System.Linq; using Cirno.Scripts.Actors._3D; using Cirno.Scripts.Resources; using Cirno.Scripts.Utils; using Godot; namespace Cirno.Scripts.UI; public partial class VendingMachineShopUi : CanvasLayer { private sealed class AmmoPurchasePlan { public required VendingMachine3D.RuntimeShopEntry Entry { get; init; } public required int BundleCount { get; init; } public required int TotalPrice { get; init; } } [Signal] public delegate void ClosedEventHandler(); [Export] public Label CreditsValueLabel { get; private set; } [Export] public ItemList ShopItemList { get; private set; } [Export] public TextureRect PreviewTexture { get; private set; } [Export] public Label ItemNameLabel { get; private set; } [Export] public Label DescriptionLabel { get; private set; } [Export] public Label PriceValueLabel { get; private set; } [Export] public Label StockValueLabel { get; private set; } [Export] public Label OwnedValueLabel { get; private set; } [Export] public Label MaxValueLabel { get; private set; } [Export] public Button AllAmmoButton { get; private set; } [Export] public Button BuyButton { get; private set; } [Export] public Button CancelButton { get; private set; } [Export] public Button CloseButton { get; private set; } [Export] public StringName CancelActionName { get; private set; } = "ui_cancel"; [Export] public bool AutoFitToViewport { get; private set; } [Export] public Vector2 ShopPanelDesignSize { get; private set; } = new(720f, 380f); [Export(PropertyHint.None, "suffix:px")] public float ViewportMargin { get; private set; } = 24f; private readonly List _entries = []; private VendingMachine3D _machine; private bool _isClosing; private bool _isReady; private int _selectedIndex = -1; private PanelContainer _rootPanel; public void Init(VendingMachine3D machine) { _machine = machine; if (_isReady) { RefreshUi(); } } public override void _Ready() { ProcessMode = ProcessModeEnum.Always; _isReady = true; _rootPanel = GetNode("Overlay/PanelContainer"); CloseButton.Pressed += CloseShop; CancelButton.Pressed += CloseShop; BuyButton.Pressed += BuySelectedItem; AllAmmoButton.Pressed += BuyAllAmmo; ShopItemList.ItemSelected += OnItemSelected; ShopItemList.ItemActivated += OnItemActivated; if (AutoFitToViewport && GetViewport() != null) { GetViewport().SizeChanged += OnViewportSizeChanged; } FitToViewport(); if (InventoryManager.Instance != null) { InventoryManager.Instance.ItemAdded += OnInventoryChanged; InventoryManager.Instance.ItemRemoved += OnInventoryChanged; } RefreshUi(); } public override void _ExitTree() { if (AutoFitToViewport && GetViewport() != null) { GetViewport().SizeChanged -= OnViewportSizeChanged; } if (InventoryManager.Instance != null) { InventoryManager.Instance.ItemAdded -= OnInventoryChanged; InventoryManager.Instance.ItemRemoved -= OnInventoryChanged; } } public override void _Process(double delta) { if (_isClosing || GameStateManager.Instance?.GameState is not GameState.Shop) { return; } if (Input.IsActionJustPressed(CancelActionName)) { CloseShop(); } } private void OnInventoryChanged(LootItem item, int currentAmount) { RefreshUi(); } private void OnInventoryChanged(string itemKey, int currentAmount) { RefreshUi(); } private void RefreshUi() { FitToViewport(); PopulateList(); UpdateCredits(); UpdateAllAmmoButton(); UpdateSelectionDetails(); if (_entries.Count > 0) { if (_selectedIndex < 0) { SelectEntry(0, true); } CallDeferred(MethodName.GrabListFocus); } } private void GrabListFocus() { ShopItemList?.GrabFocus(); } private void OnViewportSizeChanged() { FitToViewport(); } private void FitToViewport() { if (!AutoFitToViewport || _rootPanel == null) { return; } var viewport = GetViewport(); if (viewport == null) { return; } var viewportSize = viewport.GetVisibleRect().Size; var availableWidth = Mathf.Max(1f, viewportSize.X - (ViewportMargin * 2f)); var availableHeight = Mathf.Max(1f, viewportSize.Y - (ViewportMargin * 2f)); var scale = Mathf.Min(1f, Mathf.Min(availableWidth / ShopPanelDesignSize.X, availableHeight / ShopPanelDesignSize.Y)); var scaledSize = ShopPanelDesignSize * scale; _rootPanel.SetAnchorsPreset(Control.LayoutPreset.TopLeft); _rootPanel.Size = ShopPanelDesignSize; _rootPanel.PivotOffset = Vector2.Zero; _rootPanel.Scale = new Vector2(scale, scale); _rootPanel.Position = (viewportSize - scaledSize) * 0.5f; } private void PopulateList() { _entries.Clear(); ShopItemList.Clear(); if (_machine != null) { _entries.AddRange(_machine.GetEntries()); } for (var index = 0; index < _entries.Count; index++) { var entry = _entries[index]; var item = entry.Item; if (item == null) { continue; } var displayName = GetDisplayName(item); var stockLabel = entry.Unlimited ? "INF" : entry.RemainingQuantity.ToString(); var row = $"{displayName} {item.Price}c [{stockLabel}]"; ShopItemList.AddItem(row, item.InventorySprite, true); ShopItemList.SetItemTooltip(index, item.ItemDescription.ToString()); } if (_entries.Count == 0) { _selectedIndex = -1; return; } _selectedIndex = Math.Clamp(_selectedIndex, 0, _entries.Count - 1); ShopItemList.Select(_selectedIndex); } private void UpdateCredits() { var credits = InventoryManager.Instance?.GetItemCount("CREDITS") ?? 0; CreditsValueLabel.Text = credits.ToString(); } private void UpdateAllAmmoButton() { var plans = BuildAllAmmoPlan(out var totalPrice, out var canPurchase); AllAmmoButton.Text = plans.Count == 0 ? "ALL AMMO" : $"ALL AMMO ({totalPrice}c)"; AllAmmoButton.Disabled = !canPurchase; } private void UpdateSelectionDetails() { if (!TryGetSelectedEntry(out var entry)) { PreviewTexture.Texture = null; ItemNameLabel.Text = "No Stock"; DescriptionLabel.Text = "This vending machine has no shop entries configured."; PriceValueLabel.Text = "--"; StockValueLabel.Text = "--"; OwnedValueLabel.Text = "--"; MaxValueLabel.Text = "--"; BuyButton.Text = "BUY"; BuyButton.Disabled = true; return; } var item = entry.Item; var currentCount = GetCurrentCount(item); var canBuy = CanBuy(entry); PreviewTexture.Texture = item.LargePreviewSprite ?? item.InventorySprite; ItemNameLabel.Text = item.ItemName.ToString(); DescriptionLabel.Text = item.ItemDescription.ToString(); PriceValueLabel.Text = item.Price.ToString(); StockValueLabel.Text = entry.Unlimited ? "Unlimited" : entry.RemainingQuantity.ToString(); OwnedValueLabel.Text = currentCount.ToString(); MaxValueLabel.Text = item.Max.ToString(); BuyButton.Text = $"BUY ({item.Price}c)"; BuyButton.Disabled = !canBuy; } private void OnItemSelected(long index) { SelectEntry((int)index); } private void OnItemActivated(long index) { SelectEntry((int)index); BuySelectedItem(); } private void SelectEntry(int index, bool forceFocus = false) { if (_entries.Count == 0) { _selectedIndex = -1; UpdateSelectionDetails(); return; } _selectedIndex = Math.Clamp(index, 0, _entries.Count - 1); ShopItemList.Select(_selectedIndex); UpdateSelectionDetails(); if (forceFocus) { ShopItemList.GrabFocus(); } } private bool TryGetSelectedEntry(out VendingMachine3D.RuntimeShopEntry entry) { if (_selectedIndex < 0 || _selectedIndex >= _entries.Count) { entry = null; return false; } entry = _entries[_selectedIndex]; return entry?.Item != null; } private bool CanBuy(VendingMachine3D.RuntimeShopEntry entry) { var item = entry.Item; if (item == null || InventoryManager.Instance == null) { return false; } var currentCount = GetCurrentCount(item); if (currentCount >= item.Max) { return false; } return entry.CanPurchase() && InventoryManager.Instance.CanAddItem(item.ItemKey.ToString()) && InventoryManager.Instance.GetItemCount("CREDITS") >= item.Price; } private void BuySelectedItem() { if (!TryGetSelectedEntry(out var entry) || !CanBuy(entry) || InventoryManager.Instance == null) { RefreshUi(); return; } _machine.TryConsumeStock(entry.Item.ItemKey); InventoryManager.Instance.RemoveItem("CREDITS", entry.Item.Price); InventoryManager.Instance.AddItem(entry.Item); RefreshUi(); } private void BuyAllAmmo() { if (InventoryManager.Instance == null) { return; } var plans = BuildAllAmmoPlan(out var totalPrice, out var canPurchase); if (!canPurchase || plans.Count == 0) { RefreshUi(); return; } InventoryManager.Instance.RemoveItem("CREDITS", totalPrice); foreach (var plan in plans) { _machine.TryConsumeStock(plan.Entry.Item.ItemKey, plan.BundleCount); for (var purchaseIndex = 0; purchaseIndex < plan.BundleCount; purchaseIndex++) { InventoryManager.Instance.AddItem(plan.Entry.Item); } } RefreshUi(); } private List BuildAllAmmoPlan(out int totalPrice, out bool canPurchase) { totalPrice = 0; canPurchase = false; if (InventoryManager.Instance == null) { return []; } var plans = new List(); foreach (var entry in _entries.Where(entry => entry.Item?.Item == ItemTypes.Ammo)) { if (!InventoryManager.Instance.TryGetItem(entry.Item.ItemKey.ToString(), out var ownedItem)) { continue; } if (ownedItem.Count >= entry.Item.Max) { continue; } var bundleSize = Math.Max(1, entry.Item.Amount); var missingAmount = entry.Item.Max - ownedItem.Count; var bundleCount = Mathf.CeilToInt(missingAmount / (float)bundleSize); if (!entry.CanPurchase(bundleCount)) { return []; } var price = bundleCount * entry.Item.Price; plans.Add(new AmmoPurchasePlan { Entry = entry, BundleCount = bundleCount, TotalPrice = price, }); totalPrice += price; } canPurchase = plans.Count > 0 && InventoryManager.Instance.GetItemCount("CREDITS") >= totalPrice; return plans; } private int GetCurrentCount(LootItem item) { return InventoryManager.Instance?.GetItemCount(item.ItemKey.ToString()) ?? 0; } private void CloseShop() { if (_isClosing) { return; } _isClosing = true; if (GameStateManager.Instance != null) { GameStateManager.Instance.ChangeState(GameState.Playing); } EmitSignal(SignalName.Closed); QueueFree(); } private static string GetDisplayName(LootItem item) { if (!string.IsNullOrWhiteSpace(item.ShortName.ToString())) { return item.ShortName.ToString(); } return item.ItemName.ToString(); } }