diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..73d1437 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,59 @@ +name: Build Simitone + +on: + workflow_dispatch: + inputs: + configuration: + description: 'Build configuration' + required: false + default: 'Release' + type: choice + options: + - Release + - Debug + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Run Protobuild + shell: pwsh + run: | + cd FreeSO/Other/libs/FSOMonoGame/ + ./protobuild.exe --generate + continue-on-error: true + + - name: Restore Simitone dependencies + run: dotnet restore Client/Simitone/Simitone.sln + + - name: Restore FreeSO dependencies + run: dotnet restore FreeSO/TSOClient/FreeSO.sln + continue-on-error: true + + - name: Restore Roslyn dependencies + shell: pwsh + run: | + cd FreeSO/TSOClient/FSO.SimAntics.JIT.Roslyn/ + dotnet restore + continue-on-error: true + + - name: Build + run: dotnet build Client/Simitone/Simitone.sln -c ${{ inputs.configuration || 'Release' }} --no-restore + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: SimitoneWindows-${{ inputs.configuration || 'Release' }} + path: Client/Simitone/Simitone.Windows/bin/${{ inputs.configuration || 'Release' }}/net9.0-windows/ + if-no-files-found: error diff --git a/Client/Simitone/Simitone.Client/Content/Cursors/eyedropper.png b/Client/Simitone/Simitone.Client/Content/Cursors/eyedropper.png new file mode 100644 index 0000000..0d4a334 Binary files /dev/null and b/Client/Simitone/Simitone.Client/Content/Cursors/eyedropper.png differ diff --git a/Client/Simitone/Simitone.Client/Content/uigraphics/desktop/d_live_eyedropper.png b/Client/Simitone/Simitone.Client/Content/uigraphics/desktop/d_live_eyedropper.png new file mode 100644 index 0000000..0d4a334 Binary files /dev/null and b/Client/Simitone/Simitone.Client/Content/uigraphics/desktop/d_live_eyedropper.png differ diff --git a/Client/Simitone/Simitone.Client/Simitone.Client.csproj b/Client/Simitone/Simitone.Client/Simitone.Client.csproj index 1e2baf0..b5f16be 100644 --- a/Client/Simitone/Simitone.Client/Simitone.Client.csproj +++ b/Client/Simitone/Simitone.Client/Simitone.Client.csproj @@ -11,6 +11,9 @@ + + PreserveNewest + PreserveNewest @@ -89,6 +92,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/Client/Simitone/Simitone.Client/SimitoneGame.cs b/Client/Simitone/Simitone.Client/SimitoneGame.cs index 52ca85b..612e161 100644 --- a/Client/Simitone/Simitone.Client/SimitoneGame.cs +++ b/Client/Simitone/Simitone.Client/SimitoneGame.cs @@ -23,6 +23,7 @@ using MSDFData; using FSO.LotView.Model; using Simitone.Client.UI.Panels; +using Simitone.Client.Utils; namespace Simitone.Client { @@ -148,6 +149,7 @@ protected override void Initialize() { CurLoader.BmpLoaderFunc = ImageLoader.BaseFunction; GameFacade.Cursor.Init(GlobalSettings.Default.TS1HybridPath, true); + SimitoneCursors.Init(GraphicsDevice); } /** Init any computed values **/ diff --git a/Client/Simitone/Simitone.Client/UI/Controls/UITouchScroll.cs b/Client/Simitone/Simitone.Client/UI/Controls/UITouchScroll.cs index af2da19..939d639 100644 --- a/Client/Simitone/Simitone.Client/UI/Controls/UITouchScroll.cs +++ b/Client/Simitone/Simitone.Client/UI/Controls/UITouchScroll.cs @@ -197,6 +197,31 @@ public void SetScroll(float value) Scroll = value; } + public void ScrollToItem(int itemIndex) + { + // Center the item in the visible area + var targetScroll = itemIndex * ItemWidth - (GetPAxis(Size) / 2) + (ItemWidth / 2); + // Clamp to valid scroll range + var length = LengthProvider(); + Scroll = Math.Max(-Margin, Math.Min(length * ItemWidth - GetPAxis(Size) + Margin, targetScroll)); + ScrollVelocity = 0; // Stop any ongoing momentum + } + + /// + /// Selects an item by its index programmatically, triggering the visual highlight. + /// + public void SelectItem(int itemIndex) + { + if (itemIndex < 0 || itemIndex >= LengthProvider()) return; + var rItem = GetOrPrepare(itemIndex); + if (rItem != null) + { + LastSelected?.Deselected(); + rItem.Selected(); + LastSelected = rItem; + } + } + public override void Draw(UISpriteBatch batch) { if (!Visible) return; diff --git a/Client/Simitone/Simitone.Client/UI/Panels/Desktop/UIDesktopUCP.cs b/Client/Simitone/Simitone.Client/UI/Panels/Desktop/UIDesktopUCP.cs index 74602ab..4a5e3f8 100644 --- a/Client/Simitone/Simitone.Client/UI/Panels/Desktop/UIDesktopUCP.cs +++ b/Client/Simitone/Simitone.Client/UI/Panels/Desktop/UIDesktopUCP.cs @@ -25,6 +25,7 @@ public class UIDesktopUCP : UICachedContainer { public UIImage Background; public UIImage FriendIcon; + public UIButton EyedropperButton; public UIButton LiveButton; public UIButton BuyButton; @@ -90,32 +91,45 @@ public UIDesktopUCP(TS1GameScreen screen) FriendIcon = new UIImage(ui.Get("d_live_friend.png").Get(gd)) { Position = new Vector2(156, 186) }; Add(FriendIcon); - Add(LiveButton = new UIButton(ui.Get("d_live_live.png").Get(gd)) { Position = new Vector2(15, 2) }); - Add(BuyButton = new UIButton(ui.Get("d_live_buy.png").Get(gd)) { Position = new Vector2(107, 27) }); - Add(BuildButton = new UIButton(ui.Get("d_live_build.png").Get(gd)) { Position = new Vector2(179, 80) }); - Add(OptionsButton = new UIButton(ui.Get("d_live_opt.png").Get(gd)) { Position = new Vector2(242, 165) }); + // Eyedropper button - visible in Buy and Build modes + Add(EyedropperButton = new UIStencilButton(ui.Get("d_live_eyedropper.png").Get(gd)) + { + Position = new Vector2(155, 164), + Shadow = true, + ShadowParam = sDir, + Tooltip = "Eyedropper Tool (E)" + }); + EyedropperButton.OnButtonClick += ToggleEyedropper; + EyedropperButton.Visible = false; // Hidden by default (LIVE mode) - Add(FloorUpButton = new UIStencilButton(ui.Get("d_live_floorup.png").Get(gd)) { Position = new Vector2(16, 150), Shadow = true, ShadowParam = sDir }); - Add(FloorDownButton = new UIStencilButton(ui.Get("d_live_floordown.png").Get(gd)) { Position = new Vector2(16, 192), Shadow = true, ShadowParam = sDir }); + Add(LiveButton = new UIButton(ui.Get("d_live_live.png").Get(gd)) { Position = new Vector2(15, 2), Tooltip = "Live Mode" }); + Add(BuyButton = new UIButton(ui.Get("d_live_buy.png").Get(gd)) { Position = new Vector2(107, 27), Tooltip = "Buy Mode" }); + Add(BuildButton = new UIButton(ui.Get("d_live_build.png").Get(gd)) { Position = new Vector2(179, 80), Tooltip = "Build Mode" }); + Add(OptionsButton = new UIButton(ui.Get("d_live_opt.png").Get(gd)) { Position = new Vector2(242, 165), Tooltip = "Options" }); - Add(RoofButton = new UIStencilButton(ui.Get("d_live_w1.png").Get(gd)) { Position = new Vector2(15, 111), Shadow = true, ShadowParam = sDir }); - Add(WallsUpButton = new UIStencilButton(ui.Get("d_live_w2.png").Get(gd)) { Position = new Vector2(50, 107), Shadow = true, ShadowParam = sDir }); - Add(WallsCutButton = new UIStencilButton(ui.Get("d_live_w3.png").Get(gd)) { Position = new Vector2(86, 112), Shadow = true, ShadowParam = sDir }); - Add(WallsDownButton = new UIStencilButton(ui.Get("d_live_w4.png").Get(gd)) { Position = new Vector2(117, 122), Shadow = true, ShadowParam = sDir }); + Add(FloorUpButton = new UIStencilButton(ui.Get("d_live_floorup.png").Get(gd)) { Position = new Vector2(16, 150), Shadow = true, ShadowParam = sDir, Tooltip = "Floor Up" }); + Add(FloorDownButton = new UIStencilButton(ui.Get("d_live_floordown.png").Get(gd)) { Position = new Vector2(16, 192), Shadow = true, ShadowParam = sDir, Tooltip = "Floor Down" }); - Add(ZoomInButton = new UIStencilButton(ui.Get("d_live_zoomp.png").Get(gd)) { Position = new Vector2(87, 154) }); - Add(ZoomOutButton = new UIStencilButton(ui.Get("d_live_zoomm.png").Get(gd)) { Position = new Vector2(87, 196) }); - Add(RotateCWButton = new UIStencilButton(ui.Get("d_live_rotcw.png").Get(gd)) { Position = new Vector2(62, 175) }); - Add(RotateCCWButton = new UIStencilButton(ui.Get("d_live_rotccw.png").Get(gd)) { Position = new Vector2(114, 175) }); + Add(RoofButton = new UIStencilButton(ui.Get("d_live_w1.png").Get(gd)) { Position = new Vector2(15, 111), Shadow = true, ShadowParam = sDir, Tooltip = "Roof" }); + Add(WallsUpButton = new UIStencilButton(ui.Get("d_live_w2.png").Get(gd)) { Position = new Vector2(50, 107), Shadow = true, ShadowParam = sDir, Tooltip = "Walls Up" }); + Add(WallsCutButton = new UIStencilButton(ui.Get("d_live_w3.png").Get(gd)) { Position = new Vector2(86, 112), Shadow = true, ShadowParam = sDir, Tooltip = "Walls Cutaway" }); + Add(WallsDownButton = new UIStencilButton(ui.Get("d_live_w4.png").Get(gd)) { Position = new Vector2(117, 122), Shadow = true, ShadowParam = sDir, Tooltip = "Walls Down" }); + + Add(ZoomInButton = new UIStencilButton(ui.Get("d_live_zoomp.png").Get(gd)) { Position = new Vector2(87, 154), Tooltip = "Zoom In" }); + Add(ZoomOutButton = new UIStencilButton(ui.Get("d_live_zoomm.png").Get(gd)) { Position = new Vector2(87, 196), Tooltip = "Zoom Out" }); + Add(RotateCWButton = new UIStencilButton(ui.Get("d_live_rotcw.png").Get(gd)) { Position = new Vector2(62, 175), Tooltip = "Rotate Clockwise" }); + Add(RotateCCWButton = new UIStencilButton(ui.Get("d_live_rotccw.png").Get(gd)) { Position = new Vector2(114, 175), Tooltip = "Rotate Counter-Clockwise" }); SpeedButtons = new UIButton[4]; + var speedTooltips = new string[] { "Normal Speed", "Fast", "Ultra Fast", "Pause" }; for (int i=0; i<4; i++) { Add(SpeedButtons[i] = new UIStencilButton(ui.Get($"d_live_speed{i+1}.png").Get(gd)) { Position = new Vector2(158 + 30 * i, 246), Shadow = true, - ShadowParam = sDir + ShadowParam = sDir, + Tooltip = speedTooltips[i] }); var speed = i + 1; SpeedButtons[i].OnButtonClick += (btn) => @@ -322,11 +336,17 @@ public override void Update(UpdateState state) if (LastZoom != Game.ZoomLevel) UpdateZoomButton(); + // Sync eyedropper button state with ObjectHolder (for when it's auto-disabled after pick) + if (EyedropperButton.Visible && Game.LotControl?.ObjectHolder != null) + { + EyedropperButton.Selected = Game.LotControl.ObjectHolder.EyedropperMode; + } + base.Update(state); //KEY SHORTCUTS var keys = state.NewKeys; - var nofocus = true; + var nofocus = state.InputManager.GetFocus() == null; // No text input (like the cheat menu!) has focus if (Game.InLot) { if (keys.Contains(Keys.F1) && !LiveButton.Disabled) OnModeClick?.Invoke(UIMainPanelMode.LIVE); @@ -334,6 +354,12 @@ public override void Update(UpdateState state) if (keys.Contains(Keys.F3) && !BuildButton.Disabled) OnModeClick?.Invoke(UIMainPanelMode.BUILD); if (keys.Contains(Keys.F4)) OnModeClick?.Invoke(UIMainPanelMode.OPTIONS); // Options Panel + // E key for eyedropper (only in Buy/Build mode and when no text input is focused) + if (nofocus && keys.Contains(Keys.E) && EyedropperButton.Visible) + { + ToggleEyedropper(EyedropperButton); + } + if (nofocus) { var world = Game.vm.Context.World; @@ -390,12 +416,29 @@ public void UpdateZoomButton() LastZoom = Game.ZoomLevel; } + private void ToggleEyedropper(UIElement btn) + { + var holder = Game.LotControl.ObjectHolder; + holder.EyedropperMode = !holder.EyedropperMode; + EyedropperButton.Selected = holder.EyedropperMode; + } + public void SetMode(UIMainPanelMode mode) { LiveButton.Selected = mode == UIMainPanelMode.LIVE; BuyButton.Selected = mode == UIMainPanelMode.BUY; BuildButton.Selected = mode == UIMainPanelMode.BUILD; OptionsButton.Selected = mode == UIMainPanelMode.OPTIONS; + + // Show eyedropper in BUY and BUILD modes + EyedropperButton.Visible = (mode == UIMainPanelMode.BUY || mode == UIMainPanelMode.BUILD); + // Reset selection when changing modes + if (!EyedropperButton.Visible) + { + EyedropperButton.Selected = false; + if (Game.LotControl?.ObjectHolder != null) + Game.LotControl.ObjectHolder.EyedropperMode = false; + } } public void DisplayChange(int change) diff --git a/Client/Simitone/Simitone.Client/UI/Panels/LiveSubpanels/UIBuyBrowsePanel.cs b/Client/Simitone/Simitone.Client/UI/Panels/LiveSubpanels/UIBuyBrowsePanel.cs index d331844..3b9bd0c 100644 --- a/Client/Simitone/Simitone.Client/UI/Panels/LiveSubpanels/UIBuyBrowsePanel.cs +++ b/Client/Simitone/Simitone.Client/UI/Panels/LiveSubpanels/UIBuyBrowsePanel.cs @@ -329,6 +329,8 @@ static UIBuyBrowsePanel() public bool HoldingEvents; + private bool CheckedPendingEyedropper; + public UIBuyBrowsePanel(TS1GameScreen screen, sbyte category, UICatalogMode mode) : base(screen) { CatContainer = new UITouchScroll(() => FilterCategory?.Count() ?? 0, CatalogElemProvider); CatContainer.ItemWidth = 90; @@ -347,9 +349,309 @@ public UIBuyBrowsePanel(TS1GameScreen screen, sbyte category, UICatalogMode mode screen.LotControl.ObjectHolder.OnPickup += ObjectHolder_OnPickup; screen.LotControl.ObjectHolder.OnPutDown += ObjectHolder_OnPutDown; screen.LotControl.ObjectHolder.OnDelete += ObjectHolder_OnDelete; + screen.LotControl.ObjectHolder.OnEyedropperPick += ObjectHolder_OnEyedropperPick; + screen.LotControl.ObjectHolder.OnEyedropperArchitecturePick += ObjectHolder_OnEyedropperArchitecturePick; + screen.LotControl.OnCustomControlReleased += LotControl_OnCustomControlReleased; HoldingEvents = true; } + /// + /// Maps a WorldCatalog category to the Build mode UI category and subcategory. + /// Returns null if the category is not a valid Build mode object category. + /// + private static (int uiCategory, int subcatIndex)? MapCatalogToBuildCategory(int catalogCategory) + { + // Based on BuildCategories MaskBit values + switch (catalogCategory) + { + // Objects (UI Category 2) + case 8: return (2, 0); // Doors - MaskBit 7+1 + case 9: return (2, 1); // Windows - MaskBit 7+2 + case 10: return (2, 2); // Stairs - MaskBit 7+3 + case 12: return (2, 3); // Fireplaces - MaskBit 7+5 + + // Outdoors (UI Category 1) + case 11: return (1, 0); // Trees - MaskBit 7+4 + case 14: return (1, 2); // Water - MaskBit 7+7 + case 18: return (1, 1); // Terrain - MaskBit 7+11 + + // Architecture (UI Category 0) - these are special elements, not clickable objects + case 13: return (0, 0); // Walls - MaskBit 7+6 + case 15: return (0, 1); // Wallpaper - MaskBit 7+8 + case 16: return (0, 2); // Floors - MaskBit 7+9 + case 17: return (0, 3); // Roofs - MaskBit 7+10 + + default: return null; + } + } + + /// + /// Checks if there's a pending eyedropper GUID or architecture pattern to select after a category or mode switch. + /// Called from Update since Parent may not be set during constructor. + /// + private void CheckPendingEyedropperSelection() + { + if (CheckedPendingEyedropper) return; + CheckedPendingEyedropper = true; + + var mainPanel = Parent as UIMainPanel; + if (mainPanel == null) return; + + // Check for pending architecture pick first + if (mainPanel.PendingEyedropperPatternID != null && mainPanel.PendingEyedropperArchType != null) + { + var patternID = mainPanel.PendingEyedropperPatternID.Value; + var archType = mainPanel.PendingEyedropperArchType.Value; + mainPanel.PendingEyedropperPatternID = null; + mainPanel.PendingEyedropperArchType = null; + + SelectArchitectureByPatternID(patternID, archType); + return; + } + + // Check for pending GUID pick + if (mainPanel.PendingEyedropperGUID != null) + { + var guid = mainPanel.PendingEyedropperGUID.Value; + mainPanel.PendingEyedropperGUID = null; + + // Use SelectItemByGUID to handle category switching if needed + // (e.g., after a mode switch, we may be in the wrong category) + SelectItemByGUID(guid); + } + } + + private void ObjectHolder_OnEyedropperPick(uint guid) + { + // Turn off eyedropper mode (button state is synced in UIDesktopUCP) + Game.LotControl.ObjectHolder.EyedropperMode = false; + + // Select the item in catalog + SelectItemByGUID(guid); + } + + private void ObjectHolder_OnEyedropperArchitecturePick(ushort patternID, ArchitectureType archType) + { + // Turn off eyedropper mode (button state is synced in UIDesktopUCP) + Game.LotControl.ObjectHolder.EyedropperMode = false; + + // Architecture picks only work in Build mode + if (Mode != UICatalogMode.Build) + { + // Switch to Build mode first + var mainPanel = Parent as UIMainPanel; + if (mainPanel != null) + { + mainPanel.PendingEyedropperPatternID = patternID; + mainPanel.PendingEyedropperArchType = archType; + var frontend = Game.Frontend as UISimitoneFrontend; + frontend?.SwitchMode(UIMainPanelMode.BUILD); + } + return; + } + + // Already in Build mode - select the architecture item + SelectArchitectureByPatternID(patternID, archType); + } + + /// + /// Selects an architecture item (floor or wallpaper) by its pattern ID. + /// + private void SelectArchitectureByPatternID(ushort patternID, ArchitectureType archType) + { + // Architecture items are in UI Category 0 (Architecture) + // Floor = subcategory index 2, Wallpaper = subcategory index 1 + int targetUICategory = 0; + int targetSubcatIndex = (archType == ArchitectureType.Floor) ? 2 : 1; + + // Switch to Architecture category if needed + if (Category != targetUICategory) + { + var mainPanel = Parent as UIMainPanel; + if (mainPanel != null) + { + mainPanel.PendingEyedropperPatternID = patternID; + mainPanel.PendingEyedropperArchType = archType; + mainPanel.Switcher.Select(targetUICategory); + } + return; + } + + // Initialize the correct subcategory + var subcat = BuildCategories[targetUICategory][targetSubcatIndex]; + if (ChoosingSub) + { + InitSubcategory(subcat); + } + + // Find and select the item by pattern ID (matching Special.ResID) + if (FilterCategory != null) + { + int index = 0; + foreach (var elem in FilterCategory) + { + if (elem.Special?.ResID == patternID) + { + Selected(index); + CatContainer.ScrollToItem(index); + return; + } + index++; + } + } + } + + /// + /// Finds an item by GUID in the catalog and selects it. + /// If the item is in a different category, switches to that category first. + /// If the item is in a different mode (Buy vs Build), switches modes first. + /// + public void SelectItemByGUID(uint guid) + { + // First, look up the item in the world catalog to find its category + var catalogItem = Content.Get().WorldCatalog.GetItemByGUID(guid); + if (catalogItem == null) return; + + var targetCategory = catalogItem.Value.Category; + var mainPanel = Parent as UIMainPanel; + + // Determine if this is a Buy item (0-7) or Build item (8+) + bool isBuyItem = targetCategory >= 0 && targetCategory <= 7; + bool isBuildItem = targetCategory >= 8; + + // Check if we need to switch modes + if (Mode == UICatalogMode.Build && isBuyItem) + { + // In Build mode but clicked a Buy item → switch to Buy mode + if (mainPanel != null) + { + mainPanel.PendingEyedropperGUID = guid; + // Trigger mode switch to Buy + var frontend = Game.Frontend as UISimitoneFrontend; + frontend?.SwitchMode(UIMainPanelMode.BUY); + } + return; + } + else if (Mode != UICatalogMode.Build && isBuildItem) + { + // In Buy mode but clicked a Build item → switch to Build mode + if (mainPanel != null) + { + mainPanel.PendingEyedropperGUID = guid; + // Trigger mode switch to Build + var frontend = Game.Frontend as UISimitoneFrontend; + frontend?.SwitchMode(UIMainPanelMode.BUILD); + } + return; + } + + // Same mode - handle normally + if (Mode == UICatalogMode.Build) + { + // Build mode - map catalog category to Build UI category + var mapping = MapCatalogToBuildCategory(targetCategory); + if (mapping == null) return; // Not a valid build object + + var (targetUICategory, targetSubcatIndex) = mapping.Value; + + // Check if we need to switch Build UI categories + if (targetUICategory != Category) + { + if (mainPanel != null) + { + // Store for after category switch + mainPanel.PendingEyedropperGUID = guid; + mainPanel.Switcher.Select(targetUICategory); + } + return; + } + + // Same Build category - initialize the subcategory and select + if (ChoosingSub) + { + var subcat = BuildCategories[Category][targetSubcatIndex]; + InitSubcategory(subcat); + } + SelectItemInCurrentCategory(guid); + return; + } + + // Buy mode logic + if (targetCategory != Category) + { + // Store the GUID to select after category switch + if (mainPanel != null) + { + mainPanel.PendingEyedropperGUID = guid; + // Switch to the target category - this will create a new panel + mainPanel.Switcher.Select(targetCategory); + } + return; + } + + // Item is in this category - select it + SelectItemInCurrentCategory(guid); + } + + /// + /// Selects an item by GUID within the current category. + /// + private void SelectItemInCurrentCategory(uint guid) + { + // If we're still choosing subcategory, skip to show all items + if (ChoosingSub) + { + ChoosingSub = false; + foreach (var btn in SelButtons) + { + Remove(btn); + } + foreach (var label in SelLabels) + { + Remove(label); + } + SelButtons.Clear(); + SelLabels.Clear(); + + // Show all items in this category + FilterCategory = FullCategory.Where(x => GetSubsort(x.Item) > 0); + CatContainer.Opacity = 1f; + CatContainer.Reset(); + } + + // Search in FilterCategory first + if (FilterCategory != null) + { + int index = 0; + foreach (var item in FilterCategory) + { + if (item.Item.GUID == guid) + { + CatContainer.SelectItem(index); + CatContainer.ScrollToItem(index); + return; + } + index++; + } + } + + // If not found in filtered, search in FullCategory + int fullIndex = 0; + foreach (var item in FullCategory) + { + if (item.Item.GUID == guid) + { + // Show all items and select + FilterCategory = FullCategory; + CatContainer.Reset(); + CatContainer.SelectItem(fullIndex); + CatContainer.ScrollToItem(fullIndex); + return; + } + fullIndex++; + } + } + private void ObjectHolder_OnDelete(UIObjectSelection holding, UpdateState state) { Game.Frontend.MainPanel.SetSubpanelPickup(1f); @@ -357,6 +659,12 @@ private void ObjectHolder_OnDelete(UIObjectSelection holding, UpdateState state) ItemID = -1; } + private void LotControl_OnCustomControlReleased() + { + Game.LotControl.QueryPanel.Active = false; + ItemID = -1; + } + private void ObjectHolder_OnPutDown(UIObjectSelection holding, UpdateState state) { Game.LotControl.QueryPanel.Active = false; @@ -392,6 +700,10 @@ private void RemoveEvents() Game.LotControl.ObjectHolder.OnPickup -= ObjectHolder_OnPickup; Game.LotControl.ObjectHolder.OnPutDown -= ObjectHolder_OnPutDown; Game.LotControl.ObjectHolder.OnDelete -= ObjectHolder_OnDelete; + Game.LotControl.ObjectHolder.OnEyedropperPick -= ObjectHolder_OnEyedropperPick; + Game.LotControl.ObjectHolder.OnEyedropperArchitecturePick -= ObjectHolder_OnEyedropperArchitecturePick; + Game.LotControl.OnCustomControlReleased -= LotControl_OnCustomControlReleased; + Game.LotControl.ObjectHolder.EyedropperMode = false; Game.LotControl.QueryPanel.Active = false; Game.Frontend.MainPanel.SetSubpanelPickup(1f); @@ -784,6 +1096,9 @@ public override void GameResized() public override void Update(UpdateState state) { + // Check for pending eyedropper selection (after category switch) + CheckPendingEyedropperSelection(); + Invalidate(); var first = SelButtons.FirstOrDefault(); if (first != null && first.Opacity == 0) diff --git a/Client/Simitone/Simitone.Client/UI/Panels/UICheatTextbox.cs b/Client/Simitone/Simitone.Client/UI/Panels/UICheatTextbox.cs index 51ec4d6..ec60862 100644 --- a/Client/Simitone/Simitone.Client/UI/Panels/UICheatTextbox.cs +++ b/Client/Simitone/Simitone.Client/UI/Panels/UICheatTextbox.cs @@ -75,6 +75,7 @@ public override void Update(UpdateState state) && state.NewKeys.Contains(Keys.C)) //prevent change over multiple frames { Visible = !Visible; + if (!Visible) state.InputManager.SetFocus(null); // Clear focus when hiding } baseTextbox.Visible = Visible; if (Visible) @@ -83,6 +84,7 @@ public override void Update(UpdateState state) { commandEntered(baseTextbox.CurrentText, out bool shouldHide); Visible = !shouldHide; + if (!Visible) state.InputManager.SetFocus(null); // Clear focus when hiding } } } diff --git a/Client/Simitone/Simitone.Client/UI/Panels/UILotControl.cs b/Client/Simitone/Simitone.Client/UI/Panels/UILotControl.cs index 5e73ba1..45f962b 100644 --- a/Client/Simitone/Simitone.Client/UI/Panels/UILotControl.cs +++ b/Client/Simitone/Simitone.Client/UI/Panels/UILotControl.cs @@ -80,6 +80,11 @@ public uint SelectedSimID public int WallsMode = 1; + /// + /// Fired when CustomControl (floor/wallpaper tool) is released via ESC key. + /// + public event Action OnCustomControlReleased; + private int OldMX; private int OldMY; private bool FoundMe; //if false and avatar changes, center. Should center on join lot. @@ -900,15 +905,25 @@ public override void Update(UpdateState state) if (LiveMode) LiveModeUpdate(state, scrolled); else if (CustomControl != null) { - if (FSOEnvironment.SoftwareKeyboard) CustomControl.MousePosition = new Point(UIScreen.Current.ScreenWidth / 2, UIScreen.Current.ScreenHeight / 2); + // ESC cancels floor/wallpaper/wall tools + if (state.KeyboardState.IsKeyDown(Keys.Escape)) + { + CustomControl.Release(); + CustomControl = null; + OnCustomControlReleased?.Invoke(); + } else { - CustomControl.Modifiers = 0; - if (state.CtrlDown) CustomControl.Modifiers |= UILotControlModifiers.CTRL; - if (state.ShiftDown) CustomControl.Modifiers |= UILotControlModifiers.SHIFT; - CustomControl.MousePosition = state.MouseState.Position; + if (FSOEnvironment.SoftwareKeyboard) CustomControl.MousePosition = new Point(UIScreen.Current.ScreenWidth / 2, UIScreen.Current.ScreenHeight / 2); + else + { + CustomControl.Modifiers = 0; + if (state.CtrlDown) CustomControl.Modifiers |= UILotControlModifiers.CTRL; + if (state.ShiftDown) CustomControl.Modifiers |= UILotControlModifiers.SHIFT; + CustomControl.MousePosition = state.MouseState.Position; + } + CustomControl.Update(state, scrolled); } - CustomControl.Update(state, scrolled); } else ObjectHolder.Update(state, scrolled); diff --git a/Client/Simitone/Simitone.Client/UI/Panels/UIMainPanel.cs b/Client/Simitone/Simitone.Client/UI/Panels/UIMainPanel.cs index 1d98575..b76e95c 100644 --- a/Client/Simitone/Simitone.Client/UI/Panels/UIMainPanel.cs +++ b/Client/Simitone/Simitone.Client/UI/Panels/UIMainPanel.cs @@ -57,6 +57,12 @@ public float CurWidth public event Action OnEndSelect; public event Action ModeChanged; + // Eyedropper tool support - stores GUID to select after category switch + public uint? PendingEyedropperGUID; + // Architecture eyedropper - stores pattern ID and type for floors/wallpaper + public ushort? PendingEyedropperPatternID; + public ArchitectureType? PendingEyedropperArchType; + public string[] FloorNames = new string[] { "1st", diff --git a/Client/Simitone/Simitone.Client/UI/Panels/UIObjectHolder.cs b/Client/Simitone/Simitone.Client/UI/Panels/UIObjectHolder.cs index 457dbc7..f6d8280 100644 --- a/Client/Simitone/Simitone.Client/UI/Panels/UIObjectHolder.cs +++ b/Client/Simitone/Simitone.Client/UI/Panels/UIObjectHolder.cs @@ -26,6 +26,7 @@ using FSO.Common.Rendering.Framework; using FSO.Content; using FSO.Client; +using Simitone.Client.Utils; namespace Simitone.Client.UI.Panels { @@ -50,6 +51,15 @@ public class UIObjectHolder //controls the object holder interface public bool Roommate = true; public bool UseNet = false; + // Eyedropper tool support + public bool EyedropperMode; + public event EyedropperEventHandler OnEyedropperPick; + public delegate void EyedropperEventHandler(uint guid); + + // Architecture eyedropper (for floors/wallpaper which don't have GUIDs) + public event EyedropperArchitectureHandler OnEyedropperArchitecturePick; + public delegate void EyedropperArchitectureHandler(ushort patternID, ArchitectureType type); + public event HolderEventHandler OnPickup; public event HolderEventHandler OnDelete; public event HolderEventHandler OnPutDown; @@ -412,7 +422,50 @@ public void Update(UpdateState state, bool scrolled) { //not holding an object, but one can be selected var newHover = World.GetObjectIDAtScreenPos(state.MouseState.X, state.MouseState.Y, GameFacade.GraphicsDevice); - if (MouseClicked && (newHover != 0) && (vm.GetObjectById(newHover) is VMGameObject)) + + // Eyedropper mode - pick object GUID or architecture pattern + // Priority: Objects (doors/windows in walls) → Walls → Floors + if (EyedropperMode) + { + bool handled = false; + + // First, try to pick an object if one is directly under cursor + // This ensures doors/windows in walls get picked instead of the wall + if (newHover != 0 && vm.GetObjectById(newHover) is VMGameObject) + { + var obj = vm.GetObjectById(newHover); + uint guid = obj.Object.OBJ.GUID; + if (obj.MasterDefinition != null) guid = obj.MasterDefinition.GUID; + + var catalogItem = Content.Get().WorldCatalog.GetItemByGUID(guid); + if (catalogItem != null) + { + OnEyedropperPick?.Invoke(guid); + handled = true; + } + } + + // If no object, check for walls (wallpaper) + // Check for floor tiles (architecture without GUIDs) + if (!handled) + { + var tilePos = World.EstTileAtPosWithScroll( + new Vector2(state.MouseState.X, state.MouseState.Y) / FSOEnvironment.DPIScaleFactor); + var tileX = (short)Math.Floor(tilePos.X); + var tileY = (short)Math.Floor(tilePos.Y); + var level = World.State.Level; + + var floor = vm.Context.Architecture.GetFloor(tileX, tileY, level); + if (floor.Pattern > 0 && Content.Get().WorldFloors.Entries.ContainsKey(floor.Pattern)) + { + OnEyedropperArchitecturePick?.Invoke(floor.Pattern, ArchitectureType.Floor); + handled = true; + } + } + + // Don't process normal click when in eyedropper mode + } + else if (MouseClicked && (newHover != 0) && (vm.GetObjectById(newHover) is VMGameObject)) { var objGroup = vm.GetObjectById(newHover).MultitileGroup; var objBasePos = objGroup.BaseObject.Position; @@ -445,7 +498,14 @@ public void Update(UpdateState state, bool scrolled) if (ParentControl.MouseIsOn && !ParentControl.RMBScroll) { - GameFacade.Cursor.SetCursor(cur); + if (EyedropperMode && Holding == null) + { + SimitoneCursors.SetEyedropperCursor(); + } + else + { + GameFacade.Cursor.SetCursor(cur); + } } MouseWasDown = MouseIsDown; @@ -545,4 +605,12 @@ public bool IsBought } } } + + /// + /// Type of architecture element for eyedropper picks. + /// + public enum ArchitectureType + { + Floor + } } diff --git a/Client/Simitone/Simitone.Client/UI/Panels/UISimitoneFrontend.cs b/Client/Simitone/Simitone.Client/UI/Panels/UISimitoneFrontend.cs index 973515d..8853b5e 100644 --- a/Client/Simitone/Simitone.Client/UI/Panels/UISimitoneFrontend.cs +++ b/Client/Simitone/Simitone.Client/UI/Panels/UISimitoneFrontend.cs @@ -161,6 +161,15 @@ private void ExpandClicked(UIElement button) MainPanel.Switcher_OnCategorySelect(MainPanel.Switcher.ActiveCategory); } + /// + /// Programmatically switch to a different mode (Buy, Build, Live, Options). + /// Used by eyedropper to auto-switch modes when clicking cross-mode objects. + /// + public void SwitchMode(UIMainPanelMode mode) + { + LiveButtonClicked(mode); + } + private bool LiveButtonClicked(UIMainPanelMode mode) { var deskAuto = Game.Desktop && (mode != UIMainPanelMode.LIVE || MainPanel.Mode != UIMainPanelMode.LIVE); diff --git a/Client/Simitone/Simitone.Client/Utils/SimitoneCursors.cs b/Client/Simitone/Simitone.Client/Utils/SimitoneCursors.cs new file mode 100644 index 0000000..11d167b --- /dev/null +++ b/Client/Simitone/Simitone.Client/Utils/SimitoneCursors.cs @@ -0,0 +1,61 @@ +using FSO.Common; +using FSO.Common.Rendering.Framework; +using FSO.Common.Utils; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using System; +using System.IO; + +namespace Simitone.Client.Utils +{ + // Manages Simitone-specific custom cursors that aren't part of FreeSO. + public static class SimitoneCursors + { + private static MouseCursor EyedropperCursor; + private static bool Initialized; + + // Initialize Simitone-specific cursors. + public static void Init(GraphicsDevice gd) + { + if (Initialized) return; + + var pngPath = Path.Combine(FSOEnvironment.ContentDir, "Cursors", "eyedropper.png"); + + if (File.Exists(pngPath)) + { + try + { + using (var stream = File.Open(pngPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + var texture = Texture2D.FromStream(gd, stream); + // Hotspot at tip of eyedropper (adjust X,Y as needed) + EyedropperCursor = MouseCursor.FromTexture2D(texture, 1, 1); + } + } + catch (Exception) + { + EyedropperCursor = null; + } + } + + Initialized = true; + } + + // Sets the eyedropper cursor if available, otherwise uses a fallback. + public static void SetEyedropperCursor() + { + if (EyedropperCursor != null) + { + Mouse.SetCursor(EyedropperCursor); + } + else + { + // Fallback to crosshair if custom cursor not available + Mouse.SetCursor(MouseCursor.Crosshair); + } + } + + // Returns true if the eyedropper cursor was loaded successfully. + public static bool HasEyedropperCursor => EyedropperCursor != null; + } +} diff --git a/README.md b/README.md index 54e32fd..1e2c26b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ On modern operating systems, The Sims has a few nagging issues that make it less - Custom user interface that works at modern resolutions. Working on a more desktop oriented interface. - Improved graphical performance, support for high resolutions and refresh rates. - Custom lighting - directional lights with smooth falloffs and shadows using generated 3D meshes. +- Quality of life fixes that were available in later installations of the series such as the eyedropper tool (currently does not work on wallpaper) - *Volcanic*, a program which allows you to examine, modify and create new game objects. (from FreeSO) # Why is it called Simitone? @@ -27,3 +28,7 @@ On modern operating systems, The Sims has a few nagging issues that make it less Simitone -> Semitone -> musical term -> C# -> a note Further questions can be directed at my PR manager, uh, ... burglar cop. + +# Attributions + +Icon for Eyedropper tool: eyedropper icon by Icons8