From f5e95e26864f71d7e4379f8b1e36258202b0659a Mon Sep 17 00:00:00 2001 From: SegerEnd Date: Sun, 17 May 2026 15:33:03 +0200 Subject: [PATCH] Add eyedropper and sledgehammer --- .../Content/uigraphics/catalog/eyedropper.png | Bin 0 -> 687 bytes .../uigraphics/catalog/sledgehammer.png | Bin 0 -> 962 bytes .../UI/Panels/LotControls/IWallHoverTool.cs | 8 + .../Panels/LotControls/UIArchitectureTools.cs | 180 +++++++++++++++++ .../UI/Panels/LotControls/UIEyedropper.cs | 104 ++++++++++ .../UI/Panels/LotControls/UISledgehammer.cs | 150 ++++++++++++++ .../UI/Panels/LotControls/UIWallPainter.cs | 61 +++--- .../UI/Panels/UIAbstractCatalogMode.cs | 36 ++++ .../tso.client/UI/Panels/UILotControl.cs | 21 ++ .../tso.world/Components/RoofComponent.cs | 11 ++ TSOClient/tso.world/Utils/TileRaycaster.cs | 184 +++++++----------- TSOClient/tso.world/World.cs | 17 ++ 12 files changed, 625 insertions(+), 147 deletions(-) create mode 100644 TSOClient/FSO.Content.TSO/Content/uigraphics/catalog/eyedropper.png create mode 100644 TSOClient/FSO.Content.TSO/Content/uigraphics/catalog/sledgehammer.png create mode 100644 TSOClient/tso.client/UI/Panels/LotControls/IWallHoverTool.cs create mode 100644 TSOClient/tso.client/UI/Panels/LotControls/UIArchitectureTools.cs create mode 100644 TSOClient/tso.client/UI/Panels/LotControls/UIEyedropper.cs create mode 100644 TSOClient/tso.client/UI/Panels/LotControls/UISledgehammer.cs diff --git a/TSOClient/FSO.Content.TSO/Content/uigraphics/catalog/eyedropper.png b/TSOClient/FSO.Content.TSO/Content/uigraphics/catalog/eyedropper.png new file mode 100644 index 0000000000000000000000000000000000000000..ae895a7995087982d604c52a9442d65e293f8bac GIT binary patch literal 687 zcmV;g0#N;lP)Px%X-PyuR9J=W*D-6`Koke?=T1$bgO{$MbSUHtsDxxN|1834L!bm2=w!%NC>gD# zQ%Z+)C>lbjXqJX7nNHq}$dFNAAX7k-zCfLBFGKE}^U0F#WKV4#2>Z^`5BcxYlT7qj z%PD47ZxbS7`Kr(VpAIumcLoq#-CT24F&G#C{g4L!w9a|7U z@a^|6q0jxjy`_zpuSY}_xHx06C(QZIvw^!a9+#a?$Jz1rK9`)s!P^lL1y8CD)PGF? z!RB_qggjkYAtLJ6oq*{KKE5hBvYebS442}pZSB-Ll>zC<=5}8MyYH)Z88sQCh4W$8 zTi6N1Q1p7eg`Ksn9r66ds=K3Ko2u8SzN5CQo9o8jVfGv%O7z}i+k?!WLqrMBsiq!e z_8cNg^xiYC2U&$9$6Xc9mml9*c6_IBl%s+0JY zrz#x%Rw(CNg`+!izEwCg&i$ZOIJzU}mlF;EzY1~*$J%KK$JS{Ir_NExy3NY5IGJ^u zm1E^ttQ;H1X64j|!_3qW&UAk6$^5yTIt}5}J918bUKftF(-2OrGc5pKR^)8#lw?KD z0NynrDj)zJPvVlpli1FA_|?6Ko0H}+J3GTzl$>EKtelyH0Q}VOQaF}Xa?EgAJ4QHd z9WxxOl3_#u{Nsyrmg6@r2mq2{L;(D=(|wj>cjrEg2!NlaDa*05Bg2RQ_yG`S{{oH7%!qwb~^X7|%Fvj&b+Wo^e_`!&vaa(Mc<3rT~z-)wM?cVdt^t)Ex=~ V+DdKcIb8q%002ovPDHLkV1mDnM3evk literal 0 HcmV?d00001 diff --git a/TSOClient/FSO.Content.TSO/Content/uigraphics/catalog/sledgehammer.png b/TSOClient/FSO.Content.TSO/Content/uigraphics/catalog/sledgehammer.png new file mode 100644 index 0000000000000000000000000000000000000000..86e911ad695190a1e51cf92d6fcca01b3ab3b864 GIT binary patch literal 962 zcmeAS@N?(olHy`uVBq!ia0vp^CLlHk8<2d)>)QvU7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`0h7I;J!GcfQS24TkI`72U@f)XXJ5hcO-X(i=}MX3w{iJ5sNdVa1U3Z{C7 zdgk5NzFPv-Y)g&sO!M^AV&DLBSQ(@kSs56CEH5CIhO$Af(O_f-i!%Y)hKx)M0zf(n zh%?(+z~WgzHV7mDF-Sj*MzfTGnSo&fI|B<)g@KW=0pkLQsURJ!3m_&<0kT1W31|)z zSY?o<1&{^RWoTdkl2vt|*Spwc>rtS(LQfaR5D)LSQ|vRO5+#nwFTJ57`76E9+a}7Z z$$9q5LIdZEXM;A8)au5Bmj=&&x%c}+_WS-HSpTW;HHh~x@6Ot86}tPXrUbhggWbCo zM;Hs%F0lwRKD{<=TDI!Zk4H0R9hNATNbXyfxo^glMOHKSTs--t*Uu|(xl!ZCPsLaB zc;BBp+qyS(Z&k^*sd4k!KOERyF1dQ<-u*j1e27~3{`!tRk#GEyC6d1@P4qYr{bJX- z)W~_KQa&B~;HOdYHuv~e=QzD*c?OOQqF!tai?q)>J<6NIrOM2(@p=C`+iR0S%+;$` zKRV^xI*GACU3+73Av?ptRd-(n867^jAo^!k8)J=-_3D|ds>NCH50?Dx4&+Jh2{xLR z`mtQR=XtTsmPx5YCbFyFZR(J7T+%ai(#PpM4yyx*g} zd49FO(ZL&s9)Bn>c(IGS+;3l1=iNVY3?_RR7+j{^dvImnjGEoWkB)9inHK71I7x+7 z=k%j@+pX1wI8U1RPWriFE?<1F+vM##KR@~uoaua5QL^WMh3&&lADapjuAi^}05pny zgNxyz)W=btg+Ed|XQytp?G@CMzHV~%;hf^jCd-Y0Qkt%%_onUpz+2j$#J~{9V9OwX zyja8`zfI)inF)(#-d^f)tbdAxeNfULkIs+Es?YXMF`sf}O+e6QC(pB|cr`!eaAz_^ z-1b_3{`zY7^dw7FUWV*XX&cwSzy9YB!wiE>cdnQIT|cA9@Vfmv*3RK>PG cnQy$G@eb3UEo)fb)q)bDr>mdKI;Vst05B78{{R30 literal 0 HcmV?d00001 diff --git a/TSOClient/tso.client/UI/Panels/LotControls/IWallHoverTool.cs b/TSOClient/tso.client/UI/Panels/LotControls/IWallHoverTool.cs new file mode 100644 index 000000000..b21f677c7 --- /dev/null +++ b/TSOClient/tso.client/UI/Panels/LotControls/IWallHoverTool.cs @@ -0,0 +1,8 @@ +namespace FSO.Client.UI.Panels.LotControls +{ + /// Build-mode tool that highlights a wall under the cursor. + public interface IWallHoverTool + { + ArchitectureHit Hover { get; } + } +} diff --git a/TSOClient/tso.client/UI/Panels/LotControls/UIArchitectureTools.cs b/TSOClient/tso.client/UI/Panels/LotControls/UIArchitectureTools.cs new file mode 100644 index 000000000..08a99f500 --- /dev/null +++ b/TSOClient/tso.client/UI/Panels/LotControls/UIArchitectureTools.cs @@ -0,0 +1,180 @@ +using FSO.Client; +using FSO.Client.UI.Model; +using FSO.Common.Rendering.Framework.Model; +using FSO.LotView; +using FSO.LotView.Model; +using FSO.SimAntics; +using FSO.SimAntics.Model; +using FSO.SimAntics.Utils; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; + +namespace FSO.Client.UI.Panels.LotControls +{ + public enum ArchitectureHitType { None, Object, Wall, Floor } + + public struct ArchitectureHit + { + public ArchitectureHitType Type; + public Point Tile; + public sbyte Level; + public short ObjectId; + public uint ObjectGuid; + public WallSegments WallSegment; + public ushort Pattern; + public (int Primary, int Alt) PaintDir; + } + + public static class UIArchitectureTools + { + public static MouseCursor LoadCursor(string filename, int hotX, int hotY, int frameIndex = 0) + { + var gd = GameFacade.GraphicsDevice; + var strip = Content.Content.Get().CustomUI.Get(filename).Get(gd); + var size = strip.Height; + var pixels = new Color[size * size]; + strip.GetData(0, new Rectangle(size * frameIndex, 0, size, size), pixels, 0, pixels.Length); + var frame = new Texture2D(gd, size, size); + frame.SetData(pixels); + return MouseCursor.FromTexture2D(frame, hotX, hotY); + } + + public static void ShowError(UpdateState state, VMPlacementError error) + { + state.UIState.TooltipProperties.Show = true; + state.UIState.TooltipProperties.Color = Color.Black; + state.UIState.TooltipProperties.Opacity = 1; + state.UIState.TooltipProperties.Position = new Vector2(state.MouseState.X, state.MouseState.Y); + state.UIState.Tooltip = GameFacade.Strings.GetString("137", "kPErr" + error); + state.UIState.TooltipProperties.UpdateDead = false; + } + + public static void UpdateObjectErrorTooltip(UpdateState state, ArchitectureHit hover, bool valid, VMPlacementError error) + { + if (state.MouseState.LeftButton == ButtonState.Pressed && !valid && hover.Type == ArchitectureHitType.Object) + { + ShowError(state, error); + } + else + { + state.UIState.TooltipProperties.Show = false; + state.UIState.TooltipProperties.Opacity = 0; + } + } + + public static (Point pos, int direction) SegmentToEraseLine(Point tile, WallSegments segment) => segment switch + { + WallSegments.TopRight => (tile, 0), + WallSegments.TopLeft => (tile, 2), + WallSegments.BottomLeft => (new Point(tile.X, tile.Y + 1), 0), + WallSegments.BottomRight => (new Point(tile.X + 1, tile.Y), 2), + WallSegments.VerticalDiag => (tile, 1), + WallSegments.HorizontalDiag => (tile, 3), + _ => (tile, 0) + }; + + private static ushort GetSegmentPattern(WallTile wall, WallSegments segment) => segment switch + { + WallSegments.TopLeft => wall.TopLeftPattern, + WallSegments.TopRight => wall.TopRightPattern, + WallSegments.BottomRight => wall.BottomRightPattern, + WallSegments.BottomLeft or WallSegments.HorizontalDiag or WallSegments.VerticalDiag => wall.BottomLeftPattern, + _ => 0 + }; + + public static ArchitectureHit Pick(VM vm, World world, Point mouse, bool includeObjects = true) + { + var arch = vm.Context.Architecture; + var mouseV = new Vector2(mouse.X, mouse.Y); + var level = world.State.Level; + + var tilePos = world.EstTileAtPosWithScroll(mouseV); + var cursor = new Point((int)tilePos.X, (int)tilePos.Y); + var (dir, altDir) = FractToWallDir(new Vector2(tilePos.X - cursor.X, tilePos.Y - cursor.Y), world.State.CutRotation); + + var hit = world.GetWallAtScreenPos(mouseV); + if (hit.HasValue && !arch.OutsideClip((short)hit.Value.Tile.X, (short)hit.Value.Tile.Y, hit.Value.Level) + && !IsCutAway(vm, hit.Value.Tile)) + { + var rDir = SegmentToDir(hit.Value.Segment, dir); + return WallHit(arch, hit.Value.Tile, hit.Value.Level, hit.Value.Segment, rDir, rDir); + } + + if (arch.OutsideClip((short)cursor.X, (short)cursor.Y, level)) return default; + + var objId = includeObjects ? world.GetObjectIDAtScreenPos(mouse.X, mouse.Y, GameFacade.GraphicsDevice) : (short)0; + if (objId > 0) + { + var entity = vm.GetObjectById(objId); + var root = entity?.MultitileGroup?.BaseObject ?? entity; + if (root != null) return new ArchitectureHit + { + Type = ArchitectureHitType.Object, + ObjectId = root.ObjectID, + ObjectGuid = root.MasterDefinition?.GUID ?? root.Object.OBJ.GUID, + Tile = new Point(root.Position.TileX, root.Position.TileY), + Level = root.Position.Level + }; + } + + var matchedDir = VMArchitectureTools.GetPatternDirection(arch, cursor, 0, dir, altDir, level); + if (matchedDir != -1) + { + var segs = arch.GetWall((short)cursor.X, (short)cursor.Y, level).Segments; + var segment = (segs & WallSegments.AnyDiag) != 0 + ? ((segs & WallSegments.HorizontalDiag) != 0 ? WallSegments.HorizontalDiag : WallSegments.VerticalDiag) + : (WallSegments)(1 << matchedDir); + return WallHit(arch, cursor, level, segment, dir, altDir); + } + + return new ArchitectureHit + { + Type = ArchitectureHitType.Floor, + Tile = cursor, + Level = level, + Pattern = arch.GetFloor((short)cursor.X, (short)cursor.Y, level).Pattern + }; + } + + private static bool IsCutAway(VM vm, Point tile) + { + var bp = vm.Context.Blueprint; + if (bp?.Cutaway == null) return false; + if ((uint)tile.X >= (uint)bp.Width || (uint)tile.Y >= (uint)bp.Height) return false; + return bp.Cutaway[tile.Y * bp.Width + tile.X]; + } + + private static ArchitectureHit WallHit(VMArchitecture arch, Point tile, sbyte level, WallSegments segment, int primary, int alt) + { + var wall = arch.GetWall((short)tile.X, (short)tile.Y, level); + return new ArchitectureHit + { + Type = ArchitectureHitType.Wall, + Tile = tile, + Level = level, + WallSegment = segment, + Pattern = GetSegmentPattern(wall, segment), + PaintDir = (primary, alt) + }; + } + + private static (int dir, int altDir) FractToWallDir(Vector2 fract, WorldRotation rotation) => rotation switch + { + WorldRotation.BottomRight => fract.X - fract.Y > 0 ? (2, 3) : (3, 2), + WorldRotation.TopRight => fract.X + fract.Y > 1 ? (3, 0) : (0, 3), + WorldRotation.TopLeft => fract.X - fract.Y > 0 ? (1, 0) : (0, 1), + WorldRotation.BottomLeft => fract.X + fract.Y > 1 ? (2, 1) : (1, 2), + _ => (0, 1) + }; + + private static int SegmentToDir(WallSegments segment, int diagFallback) => segment switch + { + WallSegments.TopLeft => 0, + WallSegments.TopRight => 1, + WallSegments.BottomRight => 2, + WallSegments.BottomLeft => 3, + _ => diagFallback + }; + } +} diff --git a/TSOClient/tso.client/UI/Panels/LotControls/UIEyedropper.cs b/TSOClient/tso.client/UI/Panels/LotControls/UIEyedropper.cs new file mode 100644 index 000000000..071ee1287 --- /dev/null +++ b/TSOClient/tso.client/UI/Panels/LotControls/UIEyedropper.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using FSO.Client.UI.Model; +using FSO.Client.UI.Panels; +using FSO.Common.Rendering.Framework.Model; +using FSO.Content; +using FSO.HIT; +using FSO.LotView; +using FSO.LotView.Model; +using FSO.SimAntics; +using FSO.SimAntics.Entities; +using FSO.SimAntics.Model; +using FSO.SimAntics.Model.Platform; +using Microsoft.Xna.Framework.Input; + +namespace FSO.Client.UI.Panels.LotControls +{ + public class UIEyedropper : UICustomLotControl, IWallHoverTool + { + public ArchitectureHit Hover { get; private set; } + + private static MouseCursor _cursor; + private readonly VM vm; + private readonly LotView.World World; + private readonly UILotControl Parent; + private bool HoverPickable; + + public UIEyedropper(VM vm, LotView.World world, UILotControl parent, List _) + { + this.vm = vm; + this.World = parent.World; + this.Parent = parent; + Mouse.SetCursor(_cursor ??= UIArchitectureTools.LoadCursor("eyedropper.png", 2, 16, 2)); + } + + public void MouseDown(UpdateState state) + { + Hover = UIArchitectureTools.Pick(vm, World, Parent.GetScaledPoint(state.MouseState.Position)); + HoverPickable = CanPick(Hover); + + if (!HoverPickable) + { + if (Hover.Type == ArchitectureHitType.Object) + UIArchitectureTools.ShowError(state, VMPlacementError.CantBePickedup); + HITVM.Get().PlaySoundEvent(UISounds.Error); + return; + } + + switch (Hover.Type) + { + case ArchitectureHitType.Object: PickObject(Hover.ObjectGuid); break; + case ArchitectureHitType.Wall: SwitchTool(typeof(UIWallPainter), Hover.Pattern); break; + case ArchitectureHitType.Floor: SwitchTool(typeof(UIFloorPainter), Hover.Pattern); break; + } + } + + public void MouseUp(UpdateState state) { } + + public void Update(UpdateState state, bool scrolled) + { + if (Parent.MouseIsOn && !Parent.RMBScroll) Mouse.SetCursor(_cursor); + var leftHeld = state.MouseState.LeftButton == ButtonState.Pressed; + Hover = UIArchitectureTools.Pick(vm, World, Parent.GetScaledPoint(state.MouseState.Position), includeObjects: leftHeld); + HoverPickable = CanPick(Hover); + UIArchitectureTools.UpdateObjectErrorTooltip(state, Hover, HoverPickable, VMPlacementError.CantBePickedup); + } + + public void Release() => Mouse.SetCursor(MouseCursor.Arrow); + + private void PickObject(uint guid) + { + var ghost = vm.Context.CreateObjectInstance(guid, LotTilePos.OUT_OF_WORLD, Direction.NORTH, true); + if (ghost?.BaseObject == null) return; + Parent.CustomControl = null; + Parent.ObjectHolder.SetSelected(ghost); + HITVM.Get().PlaySoundEvent(UISounds.BuyPlace); + } + + private void SwitchTool(Type toolType, ushort pattern) + { + Mouse.SetCursor(MouseCursor.Arrow); + Parent.CustomControl = (UICustomLotControl)Activator.CreateInstance( + toolType, vm, World, Parent, new List { pattern }); + HITVM.Get().PlaySoundEvent(UISounds.BuildDragToolPlace); + } + + private bool CanPick(ArchitectureHit s) => s.Type switch + { + ArchitectureHitType.Wall or ArchitectureHitType.Floor => s.Pattern != 0, + ArchitectureHitType.Object => CanPickObject(s.ObjectId, s.ObjectGuid), + _ => false + }; + + private bool CanPickObject(short objectId, uint guid) + { + var entity = vm.GetObjectById(objectId); + if (entity is null or VMAvatar) return false; + if (entity.IsUserMovable(vm.Context, false) != VMPlacementError.Success) return false; + var validator = vm.TSOState?.Validator; + return validator == null + || validator.GetPurchaseMode(PurchaseMode.Normal, Parent.ActiveEntity as VMAvatar, guid, false) != PurchaseMode.Disallowed; + } + } +} diff --git a/TSOClient/tso.client/UI/Panels/LotControls/UISledgehammer.cs b/TSOClient/tso.client/UI/Panels/LotControls/UISledgehammer.cs new file mode 100644 index 000000000..bbdeaae30 --- /dev/null +++ b/TSOClient/tso.client/UI/Panels/LotControls/UISledgehammer.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using FSO.Client.UI.Model; +using FSO.Client.UI.Panels; +using FSO.Common.Rendering.Framework.Model; +using FSO.HIT; +using FSO.LotView; +using FSO.SimAntics; +using FSO.SimAntics.Entities; +using FSO.SimAntics.Model; +using FSO.SimAntics.Model.Platform; +using FSO.SimAntics.NetPlay.Model.Commands; +using Microsoft.Xna.Framework.Input; + +namespace FSO.Client.UI.Panels.LotControls +{ + public class UISledgehammer : UICustomLotControl, IWallHoverTool + { + public ArchitectureHit Hover { get; private set; } + + private static MouseCursor _cursor; + private readonly VM vm; + private readonly LotView.World World; + private readonly UILotControl Parent; + private bool HoverDeletable; + private bool WasLeftDown; + private ArchitectureHit LastDragHit; + + public UISledgehammer(VM vm, LotView.World world, UILotControl parent, List _) + { + this.vm = vm; + this.World = parent.World; + this.Parent = parent; + Mouse.SetCursor(_cursor ??= UIArchitectureTools.LoadCursor("sledgehammer.png", 4, 4, 2)); + } + + public void MouseDown(UpdateState state) + { + Hover = UIArchitectureTools.Pick(vm, World, Parent.GetScaledPoint(state.MouseState.Position)); + HoverDeletable = CanDelete(Hover); + + if (!HoverDeletable) + { + if (Hover.Type == ArchitectureHitType.Object) + UIArchitectureTools.ShowError(state, VMPlacementError.CannotDeleteObject); + HITVM.Get().PlaySoundEvent(UISounds.Error); + return; + } + ExecuteDelete(Hover); + LastDragHit = Hover; + } + + public void MouseUp(UpdateState state) + { + WasLeftDown = false; + LastDragHit = default; + } + + public void Update(UpdateState state, bool scrolled) + { + if (Parent.MouseIsOn && !Parent.RMBScroll) Mouse.SetCursor(_cursor); + var leftHeld = state.MouseState.LeftButton == ButtonState.Pressed; + Hover = UIArchitectureTools.Pick(vm, World, Parent.GetScaledPoint(state.MouseState.Position), includeObjects: leftHeld); + HoverDeletable = CanDelete(Hover); + + if (leftHeld && WasLeftDown && HoverDeletable + && Hover.Type is ArchitectureHitType.Wall or ArchitectureHitType.Floor + && !SameDragTarget(Hover, LastDragHit)) + { + ExecuteDelete(Hover); + LastDragHit = Hover; + } + WasLeftDown = leftHeld; + + UpdateFloorPreview(); + UIArchitectureTools.UpdateObjectErrorTooltip(state, Hover, HoverDeletable, VMPlacementError.CannotDeleteObject); + } + + public void Release() + { + vm.Context.Architecture.Commands.Clear(); + vm.Context.Architecture.SignalRedraw(); + Mouse.SetCursor(MouseCursor.Arrow); + } + + private void ExecuteDelete(ArchitectureHit hit) + { + switch (hit.Type) + { + case ArchitectureHitType.Object: + vm.SendCommand(new VMNetDeleteObjectCmd { ObjectID = hit.ObjectId, CleanupAll = true }); + HITVM.Get().PlaySoundEvent(UISounds.ObjectMoneyBack); + break; + case ArchitectureHitType.Wall: + var (pos, dir) = UIArchitectureTools.SegmentToEraseLine(hit.Tile, hit.WallSegment); + SendArch(new VMArchitectureCommand + { + Type = VMArchitectureCommandType.WALL_DELETE, + level = hit.Level, x = pos.X, y = pos.Y, x2 = 1, y2 = dir + }); + break; + case ArchitectureHitType.Floor: + SendArch(FloorEraseCommand(hit)); + break; + } + } + + private void UpdateFloorPreview() + { + var preview = vm.Context.Architecture.Commands; + preview.Clear(); + if (HoverDeletable && Hover.Type == ArchitectureHitType.Floor) + preview.Add(FloorEraseCommand(Hover)); + vm.Context.Architecture.SignalRedraw(); + } + + private static VMArchitectureCommand FloorEraseCommand(ArchitectureHit hit) => new() + { + Type = VMArchitectureCommandType.FLOOR_RECT, + level = hit.Level, pattern = 0, + x = hit.Tile.X, y = hit.Tile.Y, x2 = 0, y2 = 0 + }; + + private void SendArch(VMArchitectureCommand cmd) + { + vm.SendCommand(new VMNetArchitectureCmd { Commands = new List { cmd } }); + HITVM.Get().PlaySoundEvent(UISounds.BuildDragToolUp); + } + + private static bool SameDragTarget(ArchitectureHit a, ArchitectureHit b) => + a.Type == b.Type && a.Level == b.Level && a.Tile == b.Tile && a.WallSegment == b.WallSegment; + + private bool CanDelete(ArchitectureHit s) => s.Type switch + { + ArchitectureHitType.Wall => true, + ArchitectureHitType.Floor => s.Pattern != 0, + ArchitectureHitType.Object => CanDeleteObject(s.ObjectId), + _ => false + }; + + private bool CanDeleteObject(short objectId) + { + var entity = vm.GetObjectById(objectId); + if (entity is null or VMAvatar) return false; + if (entity.IsUserMovable(vm.Context, true) != VMPlacementError.Success) return false; + var validator = vm.TSOState?.Validator; + return validator == null + || validator.GetDeleteMode(DeleteMode.Delete, Parent.ActiveEntity as VMAvatar, entity) != DeleteMode.Disallowed; + } + } +} diff --git a/TSOClient/tso.client/UI/Panels/LotControls/UIWallPainter.cs b/TSOClient/tso.client/UI/Panels/LotControls/UIWallPainter.cs index acc587837..5b772150e 100644 --- a/TSOClient/tso.client/UI/Panels/LotControls/UIWallPainter.cs +++ b/TSOClient/tso.client/UI/Panels/LotControls/UIWallPainter.cs @@ -1,4 +1,4 @@ -using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework; using System.Collections.Generic; using FSO.LotView; using FSO.LotView.Components; @@ -9,13 +9,13 @@ using FSO.SimAntics.Entities; using FSO.SimAntics.Model; using FSO.SimAntics.NetPlay.Model.Commands; -using FSO.SimAntics.Utils; using FSO.Client.UI.Model; namespace FSO.Client.UI.Panels.LotControls { - public class UIWallPainter : UICustomLotControl + public class UIWallPainter : UICustomLotControl, IWallHoverTool { + public ArchitectureHit Hover { get; private set; } VMMultitileGroup WallCursor; VM vm; @@ -78,9 +78,20 @@ public void Release() public void Update(UpdateState state, bool scrolled) { ushort pattern = (state.CtrlDown) ? (ushort)0 : Pattern; + var mouse = Parent.GetScaledPoint(state.MouseState.Position); - var tilePos = World.EstTileAtPosWithScroll(Parent.GetScaledPoint(state.MouseState.Position).ToVector2()); - Point cursor = new Point((int)tilePos.X, (int)tilePos.Y); + Hover = UIArchitectureTools.Pick(vm, World, mouse, includeObjects: false); + + Point cursor; + if (Hover.Type == ArchitectureHitType.Wall) + { + cursor = Hover.Tile; + } + else + { + var tilePos = World.EstTileAtPosWithScroll(mouse.ToVector2()); + cursor = new Point((int)tilePos.X, (int)tilePos.Y); + } if (!Drawing && Commands.Count > 0) { @@ -110,44 +121,20 @@ public void Update(UpdateState state, bool scrolled) Commands.Clear(); vm.Context.Architecture.SignalRedraw(); } - int dir = 0; - int altdir = 0; - Vector2 fract = new Vector2(tilePos.X - cursor.X, tilePos.Y - cursor.Y); - switch (World.State.CutRotation) - { - case WorldRotation.BottomRight: - if (fract.X - fract.Y > 0) { dir = 2; altdir = 3; } - else { dir = 3; altdir = 2; } - break; - case WorldRotation.TopRight: - if (fract.X + fract.Y > 1) { dir = 3; altdir = 0; } - else { dir = 0; altdir = 3; } - break; - case WorldRotation.TopLeft: - //+x is right down. +y is left down - if (fract.X - fract.Y > 0) { dir = 1; altdir = 0; } - else { dir = 0; altdir = 1; } - break; - case WorldRotation.BottomLeft: - if (fract.X + fract.Y > 1) { dir = 2; altdir = 1; } - else { dir = 1; altdir = 2; } - break; - } - var finalDir = VMArchitectureTools.GetPatternDirection(vm.Context.Architecture, cursor, pattern, dir, altdir, World.State.Level); - if (finalDir != -1) + if (Hover.Type == ArchitectureHitType.Wall && Hover.Level == World.State.Level) { - CursorDir = (finalDir + 1) % 4; + var (primary, alt) = Hover.PaintDir; + CursorDir = (primary + 1) % 4; var cmd = new VMArchitectureCommand { Type = VMArchitectureCommandType.PATTERN_DOT, - level = World.State.Level, + level = Hover.Level, pattern = pattern, - style = 0, - x = cursor.X, - y = cursor.Y, - x2 = dir, - y2 = altdir + x = Hover.Tile.X, + y = Hover.Tile.Y, + x2 = primary, + y2 = alt }; if (!Commands.Contains(cmd)) { diff --git a/TSOClient/tso.client/UI/Panels/UIAbstractCatalogMode.cs b/TSOClient/tso.client/UI/Panels/UIAbstractCatalogMode.cs index 63e875807..a5ab97ade 100644 --- a/TSOClient/tso.client/UI/Panels/UIAbstractCatalogMode.cs +++ b/TSOClient/tso.client/UI/Panels/UIAbstractCatalogMode.cs @@ -37,6 +37,8 @@ public abstract class UIAbstractCatalogPanel : UICachedContainer protected UIButton SearchButton; protected UICatalogSearchPanel SearchPanel; + protected UIButton SledgehammerButton; + protected UIButton EyedropperButton; protected bool UseSmall; public UIAbstractCatalogPanel(string mode, UILotControl lotController) @@ -97,6 +99,36 @@ public UIAbstractCatalogPanel(string mode, UILotControl lotController) SearchButton.X = Background.Width - (6 + 13); SearchButton.OnButtonClick += (UIElement btn) => { SearchButton.Selected = SearchPanel.Toggle(); }; this.Add(SearchButton); + + EyedropperButton = new UIButton(ui.Get("eyedropper.png").Get(gd)); + EyedropperButton.Y = 20; + EyedropperButton.X = 20; + EyedropperButton.Tooltip = "Eyedropper"; + EyedropperButton.OnButtonClick += (UIElement btn) => ActivateTool(EyedropperButton, typeof(UIEyedropper)); + this.Add(EyedropperButton); + + SledgehammerButton = new UIButton(ui.Get("sledgehammer.png").Get(gd)); + SledgehammerButton.X = EyedropperButton.X; + SledgehammerButton.Y = EyedropperButton.Y + 25; + SledgehammerButton.Tooltip = "Sledgehammer"; + SledgehammerButton.OnButtonClick += (UIElement btn) => ActivateTool(SledgehammerButton, typeof(UISledgehammer)); + this.Add(SledgehammerButton); + } + + private void ActivateTool(UIButton sourceButton, Type toolType) + { + var toggleOff = sourceButton.Selected; + + Holder.ClearSelected(); + if (OldSelection != -1) { Catalog.SetActive(OldSelection, false); OldSelection = -1; } + LotController.CustomControl?.Release(); + LotController.CustomControl = null; + + if (toggleOff) return; + + LotController.CustomControl = (UICustomLotControl)Activator.CreateInstance( + toolType, LotController.vm, LotController.World, LotController, new List()); + AnyChanges = true; } private void SearchUpdated(string term) @@ -308,6 +340,10 @@ public override void Update(UpdateState state) SearchPanel.SetParent(this.Parent); } + var active = LotController?.CustomControl; + if (SledgehammerButton != null) SledgehammerButton.Selected = active is UISledgehammer; + if (EyedropperButton != null) EyedropperButton.Selected = active is UIEyedropper; + base.Update(state); } } diff --git a/TSOClient/tso.client/UI/Panels/UILotControl.cs b/TSOClient/tso.client/UI/Panels/UILotControl.cs index 808b02a6b..d2e39f602 100644 --- a/TSOClient/tso.client/UI/Panels/UILotControl.cs +++ b/TSOClient/tso.client/UI/Panels/UILotControl.cs @@ -1629,7 +1629,28 @@ private void UpdateCutaway(UpdateState state) LastRectCutNotable = notableChange; } } + + ApplyWallHoverLift(); + } + } + + private void ApplyWallHoverLift() + { + if (CustomControl is not LotControls.IWallHoverTool) return; + + var bp = vm.Context.Blueprint; + if (bp?.Cutaway == null) return; + var changed = false; + for (var y = MouseCutRect.Top; y < MouseCutRect.Bottom; y++) + for (var x = MouseCutRect.Left; x < MouseCutRect.Right; x++) + { + if (x < 0 || x >= bp.Width || y < 0 || y >= bp.Height) continue; + var i = y * bp.Width + x; + if (!bp.Cutaway[i]) continue; + bp.Cutaway[i] = false; + changed = true; } + if (changed) bp.Changes.SetFlag(BlueprintGlobalChanges.WALL_CUT_CHANGED); } public void Dispose() diff --git a/TSOClient/tso.world/Components/RoofComponent.cs b/TSOClient/tso.world/Components/RoofComponent.cs index 9927cf622..e32a00fde 100644 --- a/TSOClient/tso.world/Components/RoofComponent.cs +++ b/TSOClient/tso.world/Components/RoofComponent.cs @@ -147,6 +147,17 @@ public void RegenRoof(GraphicsDevice device) { RegenRoof((sbyte)(i + 1), device, indoorsMap); } + else if (RoofRects[i - 1]?.Count > 0) + { + // Whole story has no indoor tiles left, clear the old roof so it stops rendering. + RoofRects[i - 1] = null; + var dg = Drawgroups[i - 1]; + dg?.VertexBuffer?.Dispose(); + dg?.IndexBuffer?.Dispose(); + dg?.AdvVertexBuffer?.Dispose(); + dg?.AdvIndexBuffer?.Dispose(); + Drawgroups[i - 1] = null; + } } blueprint.SM64?.UpdateRoof(); diff --git a/TSOClient/tso.world/Utils/TileRaycaster.cs b/TSOClient/tso.world/Utils/TileRaycaster.cs index a48834947..3c872bf00 100644 --- a/TSOClient/tso.world/Utils/TileRaycaster.cs +++ b/TSOClient/tso.world/Utils/TileRaycaster.cs @@ -1,4 +1,4 @@ -using FSO.LotView.Model; +using FSO.LotView.Model; using Microsoft.Xna.Framework; using System.Runtime.CompilerServices; @@ -11,63 +11,54 @@ internal interface ITileRaycastTarget static abstract (float, TResult)? TestRay(Ray ray, Point tile, Point nextTile, float? edge, sbyte level, Blueprint bp); } - internal class WallTileRaycastTarget : ITileRaycastTarget + public struct WallRayHit { public WallSegments Segment; public Point Tile; } + + internal class WallTileRaycastTarget : ITileRaycastTarget { + // Wall height in world units: 2.95 floors * 3 units/floor. + private const float WallHeight = 8.85f; + [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static (float, ushort)? TestRay(Ray ray, Point tile, Point nextTile, float? edge, sbyte level, Blueprint bp) + public static (float, WallRayHit)? TestRay(Ray ray, Point tile, Point nextTile, float? edge, sbyte level, Blueprint bp) { - var wall = bp.GetWall((short)tile.X, (short)tile.Y, level); - - if (wall.Segments != 0) - { - float wallBottom = bp.GetAltitude(tile.X, tile.Y) * 3; - float wallTop = wallBottom + 2.95f * 3f; - - float? wallIntersectPoint = default; - ushort wallId = 0; - - if ((wall.Segments & WallSegments.AnyDiag) != 0) - { - var mid = new Vector3((tile.X + 0.5f) * 3, 0, (tile.Y + 0.5f) * 3); - var corner = mid + (wall.Segments.HasFlag(WallSegments.VerticalDiag) ? new Vector3(-1.5f, 0, -1.5f) : new Vector3(-1.5f, 0, 1.5f)); - var plane = new Plane(mid, mid + Vector3.Up, corner); - - var dist = ray.Intersects(plane); - if (dist.HasValue) - { - wallIntersectPoint = dist.Value; - wallId = 1; // TODO - } - } - else if ((wall.Segments & WallSegments.AnyAdj) != 0 && edge.HasValue) - { - var bound = nextTile - tile; - - WallSegments edgeSegs = 0; - if (bound.Y > 0) edgeSegs |= WallSegments.BottomLeft; - if (bound.X < 0) edgeSegs |= WallSegments.TopLeft; - if (bound.Y < 0) edgeSegs |= WallSegments.TopRight; - if (bound.X > 0) edgeSegs |= WallSegments.BottomRight; - - if ((edgeSegs & wall.Segments) != 0) - { - wallIntersectPoint = edge; - wallId = 1; //TODO - } - } + var segs = bp.GetWall((short)tile.X, (short)tile.Y, level).Segments; + if (segs == 0) return null; - if (wallIntersectPoint != null) - { - var rayY = ray.Position.Y + ray.Direction.Y * wallIntersectPoint.Value; + float hitDist; + WallSegments hitSegment; - if (rayY >= wallBottom && rayY < wallTop) - { - return (wallIntersectPoint.Value, wallId); - } - } + if ((segs & WallSegments.AnyDiag) != 0) + { + var isVertical = (segs & WallSegments.VerticalDiag) != 0; + var mid = new Vector3((tile.X + 0.5f) * 3, 0, (tile.Y + 0.5f) * 3); + var corner = mid + (isVertical ? new Vector3(-1.5f, 0, -1.5f) : new Vector3(-1.5f, 0, 1.5f)); + var d = ray.Intersects(new Plane(mid, mid + Vector3.Up, corner)); + if (!d.HasValue) return null; + hitDist = d.Value; + hitSegment = isVertical ? WallSegments.VerticalDiag : WallSegments.HorizontalDiag; + } + else + { + if ((segs & WallSegments.AnyAdj) == 0 || !edge.HasValue) return null; + + WallSegments edgeSegs = 0; + var dx = nextTile.X - tile.X; + var dy = nextTile.Y - tile.Y; + if (dy > 0) edgeSegs |= WallSegments.BottomLeft; + if (dx < 0) edgeSegs |= WallSegments.TopLeft; + if (dy < 0) edgeSegs |= WallSegments.TopRight; + if (dx > 0) edgeSegs |= WallSegments.BottomRight; + + hitSegment = edgeSegs & segs; + if (hitSegment == 0) return null; + hitDist = edge.Value; } - return null; + var wallBottom = bp.GetAltitude(tile.X, tile.Y) * 3; + var rayY = ray.Position.Y + ray.Direction.Y * hitDist; + if (rayY < wallBottom || rayY >= wallBottom + WallHeight) return null; + + return (hitDist, new WallRayHit { Segment = hitSegment, Tile = tile }); } } @@ -113,28 +104,24 @@ internal class TileRaycaster where T : ITileRaycastTarget where TResult : struct { + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static float? BoxRC2(Ray ray, float tileSize) { - var px = (ray.Direction.X > 0); - var py = (ray.Direction.Z > 0); - //find current tile - int x = (!px) ? (int)Math.Ceiling(ray.Position.X / tileSize) : - (int)(ray.Position.X / tileSize); - int y = (!py) ? (int)Math.Ceiling(ray.Position.Z / tileSize) : - (int)(ray.Position.Z / tileSize); - //find next tile boundary - float nx = ((px) ? (x + 1) : (x - 1)) * 3; - float ny = ((py) ? (y + 1) : (y - 1)) * 3; + var px = ray.Direction.X > 0; + var py = ray.Direction.Z > 0; + int x = !px ? (int)MathF.Ceiling(ray.Position.X / tileSize) : (int)(ray.Position.X / tileSize); + int y = !py ? (int)MathF.Ceiling(ray.Position.Z / tileSize) : (int)(ray.Position.Z / tileSize); + float nx = (px ? x + 1 : x - 1) * tileSize; + float ny = (py ? y + 1 : y - 1) * tileSize; const float Epsilon = 1e-6f; float? min = null; - if (Math.Abs(ray.Direction.X) > Epsilon) + if (MathF.Abs(ray.Direction.X) > Epsilon) { min = (nx - ray.Position.X) / ray.Direction.X; } - - if (Math.Abs(ray.Direction.Z) > Epsilon) + if (MathF.Abs(ray.Direction.Z) > Epsilon) { var min2 = (ny - ray.Position.Z) / ray.Direction.Z; if (min == null || min.Value > min2) min = min2; @@ -144,68 +131,49 @@ internal class TileRaycaster public static (float, TResult)? Raycast(Ray ray, sbyte level, Blueprint bp, float maxDist) { - Ray baseRay = ray; + if (bp?.Altitude == null) return null; + var baseBox = new BoundingBox(new Vector3(0, -5000, 0), new Vector3(bp.Width * 3, 5000, bp.Height * 3)); if (baseBox.Contains(ray.Position) != ContainmentType.Contains) { - //move ray start inside box var i = baseBox.Intersects(ray); - if (i != null) - { - ray.Position += ray.Direction * (i.Value + 0.01f); - } + if (i == null || i.Value > maxDist) return null; + ray.Position += ray.Direction * (i.Value + 0.01f); } var mx = (int)ray.Position.X / 3; var my = (int)ray.Position.Z / 3; - - var px = (ray.Direction.X > 0); - var py = (ray.Direction.Z > 0); - - var canProj = bp?.Altitude != null; - + var px = ray.Direction.X > 0; + var py = ray.Direction.Z > 0; float totalDist = 0; + int width = bp.Width; - int iteration = 0; - while (mx >= 0 && mx < bp.Width && my >= 0 && my < bp.Width && canProj) + for (int iteration = 0; iteration < 1000; iteration++) { - var tileDist = BoxRC2(ray, 3); // T to the next tile - - Ray nextRay = ray; + if ((uint)mx >= (uint)width || (uint)my >= (uint)width) break; + var tileDist = BoxRC2(ray, 3); if (tileDist == null) break; - float addDist = (tileDist.Value + 0.00001f); - nextRay.Position += nextRay.Direction * addDist; - - int nextX = (!px) ? ((int)Math.Ceiling(nextRay.Position.X / 3) - 1) : - (int)(nextRay.Position.X / 3); - int nextY = (!py) ? ((int)Math.Ceiling(nextRay.Position.Z / 3) - 1) : - (int)(nextRay.Position.Z / 3); + float addDist = tileDist.Value + 0.00001f; + var nextPos = ray.Position + ray.Direction * addDist; + int nextX = !px ? ((int)MathF.Ceiling(nextPos.X / 3) - 1) : (int)(nextPos.X / 3); + int nextY = !py ? ((int)MathF.Ceiling(nextPos.Z / 3) - 1) : (int)(nextPos.Z / 3); var result = T.TestRay(ray, new Point(mx, my), new Point(nextX, nextY), tileDist, level, bp); - - if (tileDist != null && result != null && result.Value.Item1 <= tileDist) + if (result != null && result.Value.Item1 <= tileDist) { - addDist = result.Value.Item1 + 0.00001f; - totalDist += addDist; - - if (totalDist > maxDist) - { - return null; - } - - // The result was hit first. - return (totalDist, result.Value.Item2); + var hitDist = totalDist + result.Value.Item1 + 0.00001f; + if (hitDist > maxDist) return null; + return (hitDist, result.Value.Item2); } - ray = nextRay; totalDist += addDist; + if (totalDist > maxDist) break; + ray.Position = nextPos; mx = nextX; my = nextY; - - if (iteration++ > 1000 || totalDist > maxDist) break; } return null; @@ -213,21 +181,17 @@ public static (float, TResult)? Raycast(Ray ray, sbyte level, Blueprint bp, floa public static (float, TResult)? RaycastMultifloor(Ray ray, Blueprint bp, float maxDist, int maxFloor = -1) { - if (maxFloor == -1) - { - maxFloor = bp.Stories; - } + if (maxFloor == -1) maxFloor = bp.Stories; (float, TResult)? bestResult = null; for (int i = 1; i <= maxFloor; i++) { var result = Raycast(ray, (sbyte)i, bp, maxDist); - if (result != null && (bestResult == null || result.Value.Item1 < bestResult.Value.Item1)) { bestResult = result; + maxDist = result.Value.Item1; } - // Next floor ray.Position -= new Vector3(0, 2.95f * 3, 0); } @@ -236,7 +200,7 @@ public static (float, TResult)? RaycastMultifloor(Ray ray, Blueprint bp, float m } } - internal class WallRaycaster : TileRaycaster + internal class WallRaycaster : TileRaycaster { } diff --git a/TSOClient/tso.world/World.cs b/TSOClient/tso.world/World.cs index 6e6a75e8b..c91e607c0 100644 --- a/TSOClient/tso.world/World.cs +++ b/TSOClient/tso.world/World.cs @@ -1001,6 +1001,23 @@ public short GetObjectIDAtScreenPos(int x, int y, GraphicsDevice gd) return Platform.GetObjectIDAtScreenPos(x, y, gd, State); } + public (Point Tile, sbyte Level, WallSegments Segment)? GetWallAtScreenPos(Vector2 screenPos) + { + if (Blueprint == null) return null; + var maxFloor = Math.Min(State.Level, Blueprint.Stories); + + (Point Tile, sbyte Level, WallSegments Segment)? best = null; + float bestDist = 1000f; + for (sbyte floor = 1; floor <= maxFloor; floor++) + { + var hit = WallRaycaster.Raycast(State.CameraRayAtScreenPos(screenPos, floor), floor, Blueprint, bestDist); + if (hit == null) continue; + bestDist = hit.Value.Item1; + best = (hit.Value.Item2.Tile, floor, hit.Value.Item2.Segment); + } + return best; + } + /// /// Gets an object group's thumbnail provided an array of objects. ///