merge remote wizden/master

This commit is contained in:
Dmitry 2026-07-15 01:46:23 +07:00
commit cf0741df48
604 changed files with 53875 additions and 44913 deletions

View file

@ -0,0 +1,42 @@
---
title: "{{ env.NAME }} test failure on main ({{ env.FULLNAME }})"
labels: autogenerated
---
## Test Failure On Main
A test failure has been detected on the main branch. This should never occur.
> [!NOTE]
> This issue is automatically generated by a github action and will be updated when this failure reoccurs.
> Do not edit the body of this issue.
## Failure Details
<dl>
<dt>Test name</dt>
<dd>{{ env.NAME }}</dd>
<dt>Fully qualified name</dt>
<dd>{{ env.FULLNAME }}</dd>
<dt>Last encountered</dt>
<dd>{{ date | date('dddd, MMMM Do') }}</dd>
</dl>
<details>
<summary>Failure message</summary>
```
{{ env.FAILURE }}
```
</details>
<details>
<summary>Test output</summary>
```
{{ env.OUTPUT }}
```
</details>
## Using this issue
Please place troubleshooting notes in the comments of this issue.

9
.github/config.yml vendored
View file

@ -1,9 +0,0 @@
Project-Condor:
org: space-wizards
project: 2
inbox: Inbox
labels:
"W: In Progress": "In Progress"
"W: Discussion": "Design and Discussion"
"W: Backlog": "Backlog"
"W: Next": "Next"

View file

@ -1,47 +0,0 @@
name: Benchmarks
on:
workflow_dispatch:
schedule:
- cron: '0 8 * * *'
concurrency: benchmarks
jobs:
benchmark:
name: Run Benchmarks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
submodules: 'recursive'
- name: Get Engine version
run: |
cd RobustToolbox
git fetch --depth=1
echo "::set-output name=out::$(git rev-parse HEAD)"
id: engine_version
- name: Run script on centcomm
uses: appleboy/ssh-action@master
with:
host: centcomm.spacestation14.io
username: robust-benchmark-runner
key: ${{ secrets.CENTCOMM_ROBUST_BENCHMARK_RUNNER_KEY }}
command_timeout: 100000m
script: |
mkdir benchmark_run_content_${{ github.sha }}
cd benchmark_run_content_${{ github.sha }}
git clone https://github.com/space-wizards/space-station-14.git repo_dir --recursive
cd repo_dir
git checkout ${{ github.sha }}
cd Content.Benchmarks
dotnet restore
export ROBUST_BENCHMARKS_ENABLE_SQL=1
export ROBUST_BENCHMARKS_SQL_ADDRESS="${{ secrets.BENCHMARKS_WRITE_ADDRESS }}"
export ROBUST_BENCHMARKS_SQL_PORT="${{ secrets.BENCHMARKS_WRITE_PORT }}"
export ROBUST_BENCHMARKS_SQL_USER="${{ secrets.BENCHMARKS_WRITE_USER }}"
export ROBUST_BENCHMARKS_SQL_PASSWORD="${{ secrets.BENCHMARKS_WRITE_PASSWORD }}"
export ROBUST_BENCHMARKS_SQL_DATABASE="content_benchmarks"
export GITHUB_SHA="${{ github.sha }}"
dotnet run --filter '*' --configuration Release
cd ../../..
rm -rf benchmark_run_content_${{ github.sha }}

View file

@ -1,41 +0,0 @@
name: Build & Publish Docfx
on:
schedule:
- cron: "0 0 * * 0"
jobs:
docfx:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Setup submodule
run: |
git submodule update --init --recursive
- name: Pull engine updates
uses: space-wizards/submodule-dependency@v0.1.5
- name: Update Engine Submodules
run: |
cd RobustToolbox/
git submodule update --init --recursive
- name: Setup .NET Core
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Install dependencies
run: dotnet restore
- name: Build Project
run: dotnet build --no-restore
- name: Build DocFX
uses: nikeee/docfx-action@v1.0.0
with:
args: Content.Docfx/docfx.json
- name: Publish Docfx Documentation on GitHub Pages
uses: maxheld83/ghpages@master
env:
BUILD_DIR: Content.Docfx/_content-site
GH_PAT: ${{ secrets.GH_PAT }}

87
.github/workflows/heisendetector.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: Hunt for Heisentests
on:
workflow_dispatch:
# every 15 minutes
schedule:
- cron: "*/15 * * * *"
jobs:
hunt:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.get_fails.outputs.matrix }}
count: ${{ steps.get_fails.outputs.count }}
steps:
- name: Checkout Master
uses: actions/checkout@v6
with:
submodules: recursive
- name: Setup .NET Core
uses: actions/setup-dotnet@v4.1.0
with:
dotnet-version: 10.0.x
- name: Install dependencies
run: dotnet restore
- name: Build Project
run: dotnet build --configuration DebugOpt --no-restore /m
- name: Run Content.Tests
id: tests
shell: pwsh
run: dotnet test --no-build --configuration DebugOpt Content.Tests/Content.Tests.csproj -- NUnit.ConsoleOut=0 NUnit.TestOutputXml="logs" NUnit.WorkDirectory="$(pwd)/test_results"
- name: Run Content.IntegrationTests
id: integration_tests
shell: pwsh
run: |
$env:DOTNET_gcServer=1
dotnet test --no-build --configuration DebugOpt Content.IntegrationTests/Content.IntegrationTests.csproj -- NUnit.ConsoleOut=0 NUnit.MapWarningTo=Failed NUnit.TestOutputXml="logs" NUnit.WorkDirectory="$(pwd)/test_results"
- name: Archive NUnit3 test results.
if: failure()
uses: actions/upload-artifact@v7
with:
name: nunit3-results
path: test_results/*
retention-days: 7
compression-level: 9
- name: Extract Failures
if: failure()
id: get_fails
run: |
python -m pip install --upgrade pip
pip install xmltodict
python Tools/actions_extract_failures.py
report:
needs: [ hunt ]
runs-on: ubuntu-latest
permissions:
issues: write
strategy:
matrix:
value: ${{ fromJSON(needs.hunt.outputs.matrix) }}
if: ${{ always() && failure() && (needs.hunt.outputs.count < 5) }}
name: Error for ${{ matrix.value.name }}
steps:
- name: Checkout Master
uses: actions/checkout@v6
- name: Create Issue
uses: dblock/create-a-github-issue@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAME: ${{ matrix.value.name }}
FULLNAME: ${{ matrix.value.fullname }}
OUTPUT: ${{ matrix.value.output }}
FAILURE: ${{ matrix.value.failure }}
with:
filename: .github/ISSUE_TEMPLATE/heisentest_template.md
update_existing: true
search_existing: all

3
.gitignore vendored
View file

@ -315,6 +315,9 @@ Resources/MapImages
*.bak
# Shader error output
error.glsl
# Direnv stuff
.direnv/

View file

@ -0,0 +1,196 @@
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using Content.IntegrationTests;
using Content.IntegrationTests.Pair;
using Content.Shared.EntityEffects;
using Robust.Shared;
using Robust.Shared.Analyzers;
using Robust.Shared.GameObjects;
namespace Content.Benchmarks;
/// <summary>
/// Benchmark comparing EntityEffect dispatch strategies.
/// Old: event bus SubscribeLocalEvent + RaiseLocalEvent
/// New: static Dictionary{{Type, IEntityEffectHandler}} + direct interface call
/// </summary>
[Virtual]
public partial class EffectDispatchBenchmark
{
private TestPair _pair = default!;
private BenchSystem _sys = default!;
[GlobalSetup]
public void Setup()
{
ProgramShared.PathOffset = "../../../../";
PoolManager.Startup(typeof(BenchSystem).Assembly);
_pair = PoolManager.GetServerClient(testContext: new ExternalTestContext("Benchmark", StreamWriter.Null)).GetAwaiter().GetResult();
var entMan = _pair.Server.EntMan;
_sys = entMan.System<BenchSystem>();
_pair.Server.WaitPost(() =>
{
var hitUid = entMan.Spawn();
entMan.AddComponent<DummyComponent>(hitUid);
_sys.HitTarget = new(hitUid, entMan.GetComponent<TransformComponent>(hitUid));
var missUid = entMan.Spawn();
_sys.MissTarget = new(missUid, entMan.GetComponent<TransformComponent>(missUid));
})
.GetAwaiter()
.GetResult();
}
[GlobalCleanup]
public async Task Cleanup()
{
await _pair.DisposeAsync();
PoolManager.Shutdown();
}
[Benchmark(Baseline = true)]
public int EventBusDispatch_Hit()
{
return _sys.RaiseViaEventBusHit();
}
[Benchmark]
public int EventBusDispatch_Miss()
{
return _sys.RaiseViaEventBusMiss();
}
[Benchmark]
public int CurrentImplementation_Hit()
{
return _sys.RaiseViaCurrentImplementation();
}
[Benchmark]
public int CurrentImplementation_Miss()
{
return _sys.RaiseViaCurrentImplementationMiss();
}
[Benchmark]
public int CSharpEventDispatch_Hit()
{
return _sys.RaiseViaCSharpEventHit();
}
[Benchmark]
public int CSharpEventDispatch_Miss()
{
return _sys.RaiseViaCSharpEventHit();
}
public sealed partial class BenchSystem : EntitySystem
{
private SharedEntityEffectsSystem _effectsSystem = default!;
public Entity<TransformComponent> HitTarget;
public Entity<TransformComponent> MissTarget;
private EntityEffect _effect = new TestEffect();
private int _counter;
public delegate void EffectHandler(EntityUid uid);
public event EffectHandler OnEffect;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<DummyComponent, TestEffectEvent>(OnEventBus);
OnEffect += OnCSharpEvent;
_effectsSystem = EntityManager.System<SharedEntityEffectsSystem>();
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void OnEventBus(Entity<DummyComponent> entity, ref TestEffectEvent args)
{
_counter++;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void OnCSharpEvent(EntityUid uid)
{
TryComp<DummyComponent>(uid, out _);
_counter++;
}
public int RaiseViaEventBusHit()
{
_counter = 0;
var ev = new TestEffectEvent();
RaiseLocalEvent(HitTarget.Owner, ref ev);
return _counter;
}
public int RaiseViaEventBusMiss()
{
_counter = 0;
var ev = new TestEffectEvent();
RaiseLocalEvent(MissTarget.Owner, ref ev);
return _counter;
}
public int RaiseViaCurrentImplementation()
{
_counter = 0;
_effectsSystem.ApplyEffect(HitTarget, _effect);
return _counter;
}
public int RaiseViaCurrentImplementationMiss()
{
_counter = 0;
_effectsSystem.ApplyEffect(MissTarget, _effect);
return _counter;
}
public int RaiseViaCSharpEventHit()
{
_counter = 0;
OnEffect?.Invoke(HitTarget.Owner);
return _counter;
}
public int RaiseViaCSharpEventMiss()
{
_counter = 0;
OnEffect?.Invoke(MissTarget.Owner);
return _counter;
}
}
[ByRefEvent]
public struct TestEffectEvent
{
}
public sealed partial class TestEffectSystem : EntityEffectSystem<DummyComponent, TestEffect>
{
protected override void Effect(Entity<DummyComponent> entity, ref EntityEffectEvent<TestEffect> args)
{
}
}
public sealed partial class TestEffect : EntityEffectBase<TestEffect>
{
}
[RegisterComponent]
public sealed partial class DummyComponent : Component
{
}
}

View file

@ -12,7 +12,7 @@ using Robust.Shared.GameObjects;
namespace Content.Benchmarks;
[Virtual]
public class RaiseEventBenchmark
public partial class RaiseEventBenchmark
{
private TestPair _pair = default!;
private BenchSystem _sys = default!;
@ -35,6 +35,48 @@ public class RaiseEventBenchmark
_sys.Ent2 = new(_sys.Ent.Owner, _sys.Ent.Comp);
_sys.NetId = fact.GetRegistration<TransformComponent>().NetID!.Value;
_sys.EvSubs = bus.GetNetCompEventHandlers<BenchSystem.BenchEv>();
var id2 = entMan.Spawn();
entMan.AddComponent<BenchSystem.Bench1Component>(id2);
entMan.AddComponent<BenchSystem.Bench2Component>(id2);
_sys.Ent2Comps = id2;
var id4 = entMan.Spawn();
entMan.AddComponent<BenchSystem.Bench1Component>(id4);
entMan.AddComponent<BenchSystem.Bench2Component>(id4);
entMan.AddComponent<BenchSystem.Bench3Component>(id4);
entMan.AddComponent<BenchSystem.Bench4Component>(id4);
_sys.Ent4Comps = id4;
var id8 = entMan.Spawn();
entMan.AddComponent<BenchSystem.Bench1Component>(id8);
entMan.AddComponent<BenchSystem.Bench2Component>(id8);
entMan.AddComponent<BenchSystem.Bench3Component>(id8);
entMan.AddComponent<BenchSystem.Bench4Component>(id8);
entMan.AddComponent<BenchSystem.Bench5Component>(id8);
entMan.AddComponent<BenchSystem.Bench6Component>(id8);
entMan.AddComponent<BenchSystem.Bench7Component>(id8);
entMan.AddComponent<BenchSystem.Bench8Component>(id8);
_sys.Ent8Comps = id8;
var id16 = entMan.Spawn();
entMan.AddComponent<BenchSystem.Bench1Component>(id16);
entMan.AddComponent<BenchSystem.Bench2Component>(id16);
entMan.AddComponent<BenchSystem.Bench3Component>(id16);
entMan.AddComponent<BenchSystem.Bench4Component>(id16);
entMan.AddComponent<BenchSystem.Bench5Component>(id16);
entMan.AddComponent<BenchSystem.Bench6Component>(id16);
entMan.AddComponent<BenchSystem.Bench7Component>(id16);
entMan.AddComponent<BenchSystem.Bench8Component>(id16);
entMan.AddComponent<BenchSystem.Bench9Component>(id16);
entMan.AddComponent<BenchSystem.Bench10Component>(id16);
entMan.AddComponent<BenchSystem.Bench11Component>(id16);
entMan.AddComponent<BenchSystem.Bench12Component>(id16);
entMan.AddComponent<BenchSystem.Bench13Component>(id16);
entMan.AddComponent<BenchSystem.Bench14Component>(id16);
entMan.AddComponent<BenchSystem.Bench15Component>(id16);
entMan.AddComponent<BenchSystem.Bench16Component>(id16);
_sys.Ent16Comps = id16;
})
.GetAwaiter()
.GetResult();
@ -48,9 +90,33 @@ public class RaiseEventBenchmark
}
[Benchmark(Baseline = true)]
public int RaiseEvent()
public int RaiseEvent1()
{
return _sys.RaiseEvent();
return _sys.RaiseEvent1();
}
[Benchmark]
public int RaiseEvent2()
{
return _sys.RaiseEvent2();
}
[Benchmark]
public int RaiseEvent4()
{
return _sys.RaiseEvent4();
}
[Benchmark]
public int RaiseEvent8()
{
return _sys.RaiseEvent8();
}
[Benchmark]
public int RaiseEvent16()
{
return _sys.RaiseEvent16();
}
[Benchmark]
@ -77,10 +143,14 @@ public class RaiseEventBenchmark
return _sys.CSharpEvent();
}
public sealed class BenchSystem : EntitySystem
public sealed partial class BenchSystem : EntitySystem
{
public Entity<TransformComponent> Ent;
public Entity<IComponent> Ent2;
public EntityUid Ent2Comps;
public EntityUid Ent4Comps;
public EntityUid Ent8Comps;
public EntityUid Ent16Comps;
public delegate void EntityEventHandler(EntityUid uid, TransformComponent comp, ref BenchEv ev);
@ -92,16 +162,60 @@ public class RaiseEventBenchmark
{
base.Initialize();
SubscribeLocalEvent<TransformComponent, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench1Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench2Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench3Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench4Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench5Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench6Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench7Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench8Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench9Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench10Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench11Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench12Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench13Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench14Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench15Component, BenchEv>(OnEvent);
SubscribeLocalEvent<Bench16Component, BenchEv>(OnEvent);
OnCSharpEvent += OnEvent;
}
public int RaiseEvent()
public int RaiseEvent1()
{
var ev = new BenchEv();
RaiseLocalEvent(Ent.Owner, ref ev);
return ev.N;
}
public int RaiseEvent2()
{
var ev = new BenchEv();
RaiseLocalEvent(Ent2Comps, ref ev);
return ev.N;
}
public int RaiseEvent4()
{
var ev = new BenchEv();
RaiseLocalEvent(Ent4Comps, ref ev);
return ev.N;
}
public int RaiseEvent8()
{
var ev = new BenchEv();
RaiseLocalEvent(Ent8Comps, ref ev);
return ev.N;
}
public int RaiseEvent16()
{
var ev = new BenchEv();
RaiseLocalEvent(Ent16Comps, ref ev);
return ev.N;
}
public int RaiseCompEvent()
{
var ev = new BenchEv();
@ -134,7 +248,7 @@ public class RaiseEventBenchmark
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void OnEvent(EntityUid uid, TransformComponent component, ref BenchEv args)
private void OnEvent<T>(EntityUid uid, T component, ref BenchEv args)
{
args.N += uid.Id;
}
@ -145,5 +259,53 @@ public class RaiseEventBenchmark
{
public int N;
}
[RegisterComponent]
public sealed partial class Bench1Component : Component;
[RegisterComponent]
public sealed partial class Bench2Component : Component;
[RegisterComponent]
public sealed partial class Bench3Component : Component;
[RegisterComponent]
public sealed partial class Bench4Component : Component;
[RegisterComponent]
public sealed partial class Bench5Component : Component;
[RegisterComponent]
public sealed partial class Bench6Component : Component;
[RegisterComponent]
public sealed partial class Bench7Component : Component;
[RegisterComponent]
public sealed partial class Bench8Component : Component;
[RegisterComponent]
public sealed partial class Bench9Component : Component;
[RegisterComponent]
public sealed partial class Bench10Component : Component;
[RegisterComponent]
public sealed partial class Bench11Component : Component;
[RegisterComponent]
public sealed partial class Bench12Component : Component;
[RegisterComponent]
public sealed partial class Bench13Component : Component;
[RegisterComponent]
public sealed partial class Bench14Component : Component;
[RegisterComponent]
public sealed partial class Bench15Component : Component;
[RegisterComponent]
public sealed partial class Bench16Component : Component;
}
}

View file

@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using Content.Client.Administration.Systems;
using Content.Client.Stylesheets;
using Content.Shared.Administration;
@ -59,7 +59,7 @@ internal sealed partial class AdminQuickInfoSystem : EntitySystem
public void OpenPopupFor(NetEntity[] entities)
{
var vBox = new VBox();
var vBox = new VBox() { SeparationOverride = 6 };;
var popup = new Popup
{
Children =
@ -74,9 +74,19 @@ internal sealed partial class AdminQuickInfoSystem : EntitySystem
},
},
};
var first = true;
foreach (var entity in entities)
{
if (!first)
{
vBox.AddChild(new PanelContainer
{
StyleClasses = { StyleClass.LowDivider },
HorizontalExpand = true,
});
first = false;
}
var playerInfo = _adminSystem.PlayerList.FirstOrDefault(p => p.NetEntity == entity);
var control = new InfoControl(this, entity, playerInfo);
popup.OnPopupHide += () => control.Unsubscribe();
@ -92,19 +102,21 @@ internal sealed partial class AdminQuickInfoSystem : EntitySystem
{
private readonly AdminQuickInfoSystem _system;
private readonly NetEntity _entity;
private readonly PlayerInfo? _playerInfo;
private readonly bool _activeControl;
private PlayerInfo? _playerInfo;
private QuickInfoShared.SingleEntityInfo? _response;
private readonly RichTextLabel _contents = new();
private ILocalizationManager Loc => _system.Loc;
private AdminSystem Admin => _system._adminSystem;
public InfoControl(AdminQuickInfoSystem system, NetEntity entity, PlayerInfo? playerInfo)
{
_system = system;
_entity = entity;
_playerInfo = playerInfo;
_activeControl = (playerInfo != null);
AddChild(_contents);
system.EntityResponseReceived += OnEntityResponseReceived;
@ -119,6 +131,7 @@ internal sealed partial class AdminQuickInfoSystem : EntitySystem
return;
_response = ev;
_playerInfo ??= Admin.PlayerList.FirstOrDefault(p => p.SessionId == ev.LastPlayer);
Rebuild();
}
@ -131,6 +144,10 @@ internal sealed partial class AdminQuickInfoSystem : EntitySystem
private void Rebuild()
{
var sb = new FormattedStringBuilder();
if (!_activeControl)
{
sb.AppendMarkupLine(Loc.GetString("admin-quick-info-inactive"));
}
if (_playerInfo != null)
{

View file

@ -106,13 +106,35 @@ public sealed partial class NoteEdit : FancyWindow
{
PermanentCheckBox.Pressed = false;
UpdatePermanentCheckboxFields();
ExpiryLineEdit.Text = ExpiryTime.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
var timeLeft = ConvertDateToTimeFromNow(ExpiryTime.Value.ToLocalTime());
ExpiryLineEdit.Text = Math.Round(timeLeft.Item2, 2).ToString();
ExpiryLengthDropdown.SelectId((int)timeLeft.Item1);
}
}
UpdateSubmitButton();
}
// Convert the given date time into a multiplier and value.
// This is for having a simple format like 2 weeks instead of everything being in hours.
// For example, a 2 weeks old date would return (Multipliers.Days, 14)
private (Multipliers, double) ConvertDateToTimeFromNow(DateTime expirationDate)
{
var deltaTime = expirationDate - DateTime.Now;
if (deltaTime.TotalMinutes <= 0)
return (Multipliers.Minutes, 0.0);
return deltaTime.TotalDays switch
{
< 1 => (Multipliers.Minutes, deltaTime.TotalMinutes), // Less than a day
< 365 => (Multipliers.Days, deltaTime.TotalDays), // Less than a year
_ => (Multipliers.Months, deltaTime.TotalDays / 30) // More than a year
};
}
private void OnSubmitButtonMouseEntered(GUIMouseHoverEventArgs args)
{
if (!SubmitButton.Disabled)
@ -300,7 +322,7 @@ public sealed partial class NoteEdit : FancyWindow
return true;
}
if (string.IsNullOrWhiteSpace(ExpiryLineEdit.Text) || !uint.TryParse(ExpiryLineEdit.Text, out var inputInt))
if (string.IsNullOrWhiteSpace(ExpiryLineEdit.Text) || !double.TryParse(ExpiryLineEdit.Text, out var inputDouble) || inputDouble < 0)
{
ExpiryLineEdit.ModulateSelfOverride = Color.Red;
return false;
@ -317,7 +339,8 @@ public sealed partial class NoteEdit : FancyWindow
(int) Multipliers.Centuries => TimeSpan.FromDays(36525).TotalMinutes,
_ => throw new ArgumentOutOfRangeException(nameof(ExpiryLengthDropdown.SelectedId), "Multiplier out of range :(")
};
ExpiryTime = DateTime.UtcNow.AddMinutes(inputInt * mult);
ExpiryTime = DateTime.UtcNow.AddMinutes(inputDouble * mult);
ExpiryLineEdit.ModulateSelfOverride = null;
return true;
}
@ -339,4 +362,4 @@ public sealed partial class NoteEdit : FancyWindow
SubmitPressed = null;
}
}
}

View file

@ -9,6 +9,7 @@ using Robust.Client.Player;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Configuration;
using Robust.Shared.Prototypes;
using static Content.Client.Administration.UI.Tabs.PlayerTab.PlayerTabHeader;
using static Robust.Client.UserInterface.Controls.BaseButton;
@ -20,6 +21,7 @@ public sealed partial class PlayerTab : Control
[Dependency] private IEntityManager _entManager = default!;
[Dependency] private IConfigurationManager _config = default!;
[Dependency] private IPlayerManager _playerMan = default!;
[Dependency] private IPrototypeManager _proto = default!;
private const string ArrowUp = "↑";
private const string ArrowDown = "↓";
@ -155,7 +157,12 @@ public sealed partial class PlayerTab : Control
UpdateHeaderSymbols();
SearchList.PopulateList(sortedPlayers.Select(info => new PlayerListData(info,
$"{info.Username} {info.CharacterName} {info.IdentityName} {info.StartingJob}"))
$"{info.Username} " +
$"{info.CharacterName} " +
$"{info.IdentityName} " +
$"{info.StartingJob} " +
$"{(info.Subtype is not null ? Loc.GetString(info.Subtype) : string.Empty)} " +
$"{(_proto.TryIndex(info.RoleProto, out var proto) ? Loc.GetString(proto.Name) : string.Empty)}"))
.ToList());
}

View file

@ -7,9 +7,9 @@
<BoxContainer Orientation="Vertical" SeparationOverride="8">
<GridContainer Columns="4">
<controls:ConfirmButton Name="ServerShutdownButton" Text="{Loc server-shutdown}" />
<cc:CommandButton Name="SetOocButton" Command="setooc" Text="{Loc server-ooc-toggle}" ToggleMode="True" />
<cc:CommandButton Name="SetLoocButton" Command="setlooc" Text="{Loc server-looc-toggle}" ToggleMode="True" />
<cc:CommandButton Name="SetDeadChatButton" Command="setdeadchat" Text="{Loc server-dead-chat-toggle}" ToggleMode="True" />
<cc:CommandButton Name="SetOocButton" Command="togglechat OOC" Text="{Loc server-ooc-toggle}" ToggleMode="True" />
<cc:CommandButton Name="SetLoocButton" Command="togglechat LOOC" Text="{Loc server-looc-toggle}" ToggleMode="True" />
<cc:CommandButton Name="SetDeadChatButton" Command="togglechat Dead" Text="{Loc server-dead-chat-toggle}" ToggleMode="True" />
</GridContainer>
<BoxContainer Orientation="Vertical">

View file

@ -1,3 +1,4 @@
using Content.Client.DisplacementMap;
using Content.Shared.Anomaly.Components;
using Content.Shared.Anomaly.Effects;
using Content.Shared.Humanoid;
@ -8,6 +9,9 @@ namespace Content.Client.Anomaly.Effects;
public sealed partial class ClientInnerBodyAnomalySystem : SharedInnerBodyAnomalySystem
{
[Dependency] private SpriteSystem _sprite = default!;
[Dependency] private DisplacementMapSystem _displacement = default!;
[Dependency] private EntityQuery<InnerBodyAnomalyVisualsComponent> _visualsQuery = default!;
public override void Initialize()
{
@ -37,6 +41,22 @@ public sealed partial class ClientInnerBodyAnomalySystem : SharedInnerBodyAnomal
_sprite.LayerSetVisible((ent.Owner, sprite), index, true);
sprite.LayerSetShader(index, "unshaded");
if (_visualsQuery.TryGetComponent(ent, out var visuals) && visuals.Displacement != null)
{
if (ProtoMan.Resolve(visuals.Displacement, out var displacement))
{
_displacement.TryAddDisplacement(displacement.Displacement,
(ent.Owner, sprite),
index,
ent.Comp.LayerMap,
out _);
}
else
{
_displacement.EnsureDisplacementIsNotOnSprite((ent.Owner, sprite), ent.Comp.LayerMap);
}
}
}
private void OnCompShutdown(Entity<InnerBodyAnomalyComponent> ent, ref ComponentShutdown args)
@ -46,5 +66,7 @@ public sealed partial class ClientInnerBodyAnomalySystem : SharedInnerBodyAnomal
var index = _sprite.LayerMapGet((ent.Owner, sprite), ent.Comp.LayerMap);
_sprite.LayerSetVisible((ent.Owner, sprite), index, false);
_displacement.EnsureDisplacementIsNotOnSprite((ent.Owner, sprite), ent.Comp.LayerMap);
}
}

View file

@ -1,3 +1,6 @@
using Content.Shared.DisplacementMap;
using Robust.Shared.Prototypes;
namespace Content.Client.Atmos.Components;
/// <summary>
@ -39,4 +42,10 @@ public sealed partial class FireVisualsComponent : Component
/// the burning entity as entities don't support having multiple point-lights.
/// </summary>
public EntityUid? LightEntity;
/// <summary>
/// Tracks the currently applied displacement, so that only new changes update the layer.
/// </summary>
[DataField]
public ProtoId<DisplacementDataPrototype>? CurrentDisplacement;
}

View file

@ -1,5 +1,7 @@
using Content.Client.Atmos.Components;
using Content.Client.DisplacementMap;
using Content.Shared.Atmos;
using Content.Shared.DisplacementMap;
using Robust.Client.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Utility;
@ -12,6 +14,7 @@ namespace Content.Client.Atmos.EntitySystems;
public sealed partial class FireVisualizerSystem : VisualizerSystem<FireVisualsComponent>
{
[Dependency] private PointLightSystem _lights = default!;
[Dependency] private DisplacementMapSystem _displacement = default!;
public override void Initialize()
{
@ -65,6 +68,7 @@ public sealed partial class FireVisualizerSystem : VisualizerSystem<FireVisualsC
AppearanceSystem.TryGetData<bool>(uid, FireVisuals.OnFire, out var onFire, appearance);
AppearanceSystem.TryGetData<float>(uid, FireVisuals.FireStacks, out var fireStacks, appearance);
AppearanceSystem.TryGetData<string?>(uid, FireVisuals.FireDisplacement, out var fireDisplacement, appearance);
SpriteSystem.LayerSetVisible((uid, sprite), index, onFire);
if (!onFire)
@ -83,6 +87,16 @@ public sealed partial class FireVisualizerSystem : VisualizerSystem<FireVisualsC
else
SpriteSystem.LayerSetRsiState((uid, sprite), index, component.NormalState);
if (component.CurrentDisplacement != fireDisplacement)
{
if (fireDisplacement != null && ProtoMan.Resolve<DisplacementDataPrototype>(fireDisplacement, out var displacementProto))
_displacement.TryAddDisplacement(displacementProto.Displacement, (uid, sprite), index, FireVisualLayers.Fire, out _);
else
_displacement.EnsureDisplacementIsNotOnSprite((uid, sprite), FireVisualLayers.Fire);
component.CurrentDisplacement = fireDisplacement;
}
component.LightEntity ??= Spawn(null, new EntityCoordinates(uid, default));
var light = EnsureComp<PointLightComponent>(component.LightEntity.Value);

View file

@ -70,7 +70,7 @@ public sealed partial class GasTileHeatBlurOverlay : Overlay
_heatGradientTexture = _resourceCache.GetTexture("/Textures/Effects/HeatBlur/soft_circle.png");
_shader = _proto.Index(HeatOverlayShader).InstanceUnique();
_configManager.OnValueChanged(CCVars.ReducedMotion, SetReducedMotion, invokeImmediately: true);
_configManager.OnValueChanged(CCVars.DisableHeatDistortion, SetReducedMotion, invokeImmediately: true);
}
private void SetReducedMotion(bool reducedMotion)
@ -227,7 +227,7 @@ public sealed partial class GasTileHeatBlurOverlay : Overlay
{
_resources.Dispose();
_configManager.UnsubValueChanged(CCVars.ReducedMotion, SetReducedMotion);
_configManager.UnsubValueChanged(CCVars.DisableHeatDistortion, SetReducedMotion);
base.DisposeBehavior();
}

View file

@ -22,7 +22,6 @@ public sealed partial class ContentAudioSystem
{
[Dependency] private IConfigurationManager _configManager = default!;
[Dependency] private IGameTiming _timing = default!;
[Dependency] private ILogManager _logManager = default!;
[Dependency] private IPlayerManager _player = default!;
[Dependency] private IRobustRandom _random = default!;
[Dependency] private IStateManager _state = default!;
@ -59,7 +58,7 @@ public sealed partial class ContentAudioSystem
private void InitializeAmbientMusic()
{
Subs.CVar(_configManager, CCVars.AmbientMusicVolume, AmbienceCVarChanged, true);
_sawmill = _logManager.GetSawmill("audio.ambience");
_sawmill = LogManager.GetSawmill("audio.ambience");
// Reset audio
_nextAudio = TimeSpan.MaxValue;

View file

@ -0,0 +1,201 @@
using System.IO;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Robust.Shared.ContentPack;
using Robust.Shared.Utility;
namespace Content.Client.Audio.Midi;
/// <summary>
/// Handles storage/management of MIDI files stored inside the user data directory.
/// </summary>
[PublicAPI]
public sealed partial class MidiFileCollectionManager : IPostInjectInit
{
/// <summary>
/// Directory path to use inside UserData for storing MIDIs.
/// </summary>
private static readonly ResPath UserMidiDirectory = new("/UserMidis/");
private const string SawmillCategory = "midifilecollection";
[Dependency] private IResourceManager _resManager = default!;
[Dependency] private ILogManager _logManager = default!;
private ISawmill _sawmill = default!;
private readonly List<ResPath> _filePaths = [];
/// <summary>
/// Raised after a MIDI file has been added to the library. Contains added file path as argument.
/// </summary>
public event Action<ResPath>? MidiFileAdded;
/// <summary>
/// Raised after a MIDI file has been removed from the library. Contains removed file path as argument.
/// </summary>
public event Action<ResPath>? MidiFileRemoved;
/// <summary>
/// Raised after more than one or two library changes occured at once. (i.e. initial load or when all items deleted)
/// </summary>
public event Action? MidiFilesReset;
///<inheritdoc cref="IPostInjectInit"/>
public void PostInject()
{
EnsureMidiDirectoryExists();
ReloadLibrary();
_sawmill = _logManager.GetSawmill(SawmillCategory);
}
/// <summary>
/// Returns the binary content of the given MIDI file.
/// </summary>
/// <param name="filePath">MIDI file path to get.</param>
/// <returns>MIDI binary as a byte array or an empty byte array if the file doesn't exist.</returns>
public byte[] GetMidiData(ResPath filePath)
{
try
{
var fullPath = UserMidiDirectory / filePath;
return _resManager.UserData.ReadAllBytes(fullPath);
}
catch (Exception e)
{
_sawmill.Error($"Failed to read MIDI data from '{filePath}': {e.Message}");
return [];
}
}
/// <summary>
/// Returns an enumeration of all available MIDI files.
/// </summary>
/// <returns>An enumeration of MIDI file paths.</returns>
public IEnumerable<ResPath> GetMidiFiles()
{
return _filePaths;
}
/// <summary>
/// Stores the given byte stream with the given file path inside the <see cref="UserMidiDirectory"/> directory.
/// </summary>
/// <param name="filePath">File path to write.</param>
/// <param name="data">Binary data to write.</param>
/// <remarks>Raises <see cref="MidiFileAdded"/> on success.</remarks>
public async Task<bool> AddMidiFile(ResPath filePath, Stream data)
{
try
{
await using var file = _resManager.UserData.OpenWrite(UserMidiDirectory / filePath);
await data.CopyToAsync(file);
_filePaths.Add(filePath);
MidiFileAdded?.Invoke(filePath);
return true;
}
catch (Exception e)
{
_sawmill.Error($"Failed to store MIDI file '{filePath}' in library: {e.Message}");
return false;
}
}
/// <summary>
/// Stores the given byte array with the given file path inside the <see cref="UserMidiDirectory"/> directory.
/// </summary>
/// <param name="filePath">File path to write.</param>
/// <param name="data">Binary data to write.</param>
/// <remarks>Raises <see cref="MidiFileAdded"/> on success.</remarks>
public async Task<bool> AddMidiFile(ResPath filePath, byte[] data)
{
return await AddMidiFile(filePath, new MemoryStream(data));
}
/// <summary>
/// Renames a MIDI file inside the library.
/// </summary>
/// <param name="oldPath">Current file path</param>
/// <param name="newPath">New file path</param>
/// <remarks>Raises <see cref="MidiFileRemoved"/> and <see cref="MidiFileAdded"/> on success.</remarks>
public bool RenameMidiFile(ResPath oldPath, ResPath newPath)
{
try
{
var fullOldPath = UserMidiDirectory / oldPath;
var fullNewPath = UserMidiDirectory / newPath;
fullOldPath = fullOldPath.Clean();
fullNewPath = fullNewPath.Clean();
_resManager.UserData.Rename(fullOldPath, fullNewPath);
_filePaths.Remove(oldPath);
MidiFileRemoved?.Invoke(oldPath);
_filePaths.Add(newPath);
MidiFileAdded?.Invoke(newPath);
return true;
}
catch (Exception e)
{
_sawmill.Error($"Failed to rename MIDI file '{oldPath}' with '{newPath}': {e.Message}");
return false;
}
}
/// <summary>
/// Permanently removes the given MIDI file if it exists inside <see cref="UserMidiDirectory"/>.
/// </summary>
/// <param name="filePath">File path to remove.</param>
/// <remarks>Raises <see cref="MidiFileRemoved"/></remarks>
public void RemoveMidiFile(ResPath filePath)
{
DeleteMidiFile(filePath);
_filePaths.Remove(filePath);
MidiFileRemoved?.Invoke(filePath);
}
/// <summary>
/// Removes all registered MIDI files, permanently.
/// </summary>
/// <remarks>Raises <see cref="MidiFilesReset"/></remarks>
public void RemoveAllMidiFiles()
{
foreach (var path in _filePaths)
{
DeleteMidiFile(path);
}
_filePaths.Clear();
MidiFilesReset?.Invoke();
}
/// <summary>
/// Clears and reloads the entire MIDI library.
/// </summary>
public void ReloadLibrary()
{
_filePaths.Clear();
if (!_resManager.UserData.IsDir(UserMidiDirectory))
return;
foreach (var path in _resManager.UserData.DirectoryEntries(UserMidiDirectory))
{
var filePath = new ResPath(UserMidiDirectory + path);
if (!filePath.Extension.Equals("midi") && !filePath.Extension.Equals("mid"))
continue;
_filePaths.Add(new ResPath(path));
}
MidiFilesReset?.Invoke();
}
private void DeleteMidiFile(ResPath filePath)
{
var path = (UserMidiDirectory / filePath).Clean();
_resManager.UserData.Delete(path);
}
private void EnsureMidiDirectoryExists()
{
if (!_resManager.UserData.Exists(UserMidiDirectory))
_resManager.UserData.CreateDir(UserMidiDirectory);
}
}

View file

@ -2,6 +2,7 @@ using System.Linq;
using Content.Client.DisplacementMap;
using Content.Shared.Body;
using Content.Shared.CCVar;
using Content.Shared.DisplacementMap;
using Content.Shared.Humanoid.Markings;
using Content.Shared.Humanoid;
using Robust.Client.GameObjects;
@ -73,6 +74,16 @@ public sealed partial class VisualBodySystem : SharedVisualBodySystem
return;
_sprite.LayerSetData(target, index, ent.Comp.Data);
var displacement = ent.Comp.Displacement;
if (displacement != null && ProtoMan.Resolve(displacement, out var displacementProto))
{
_displacement.TryAddDisplacement(displacementProto.Displacement,
(target, Comp<SpriteComponent>(target)),
index,
ent.Comp.Layer,
out _);
}
}
private void RemoveVisual(Entity<VisualOrganComponent> ent, EntityUid target)
@ -81,6 +92,8 @@ public sealed partial class VisualBodySystem : SharedVisualBodySystem
return;
_sprite.LayerSetRsiState(target, index, RSI.StateId.Invalid);
_displacement.EnsureDisplacementIsNotOnSprite((target, Comp<SpriteComponent>(target)), ent.Comp.Layer);
}
private void OnMarkingsGotInserted(Entity<VisualOrganMarkingsComponent> ent, ref OrganGotInsertedEvent args)
@ -183,6 +196,7 @@ public sealed partial class VisualBodySystem : SharedVisualBodySystem
ent.Comp.MarkingsDisplacement.TryGetValue(proto.BodyPart, out var displacement);
var numDisplacements = 0;
for (var i = 0; i < proto.Sprites.Count; i++)
{
var sprite = proto.Sprites[i];
@ -195,7 +209,14 @@ public sealed partial class VisualBodySystem : SharedVisualBodySystem
if (!_sprite.LayerMapTryGet(target, layerId, out _, false))
{
var spriteLayer = _sprite.AddLayer(target, sprite, index + i + 1);
// Having three separate indices and a magic +1 is cursed, but:
// - index refers to the index of the organ the marking is applied to
// - i is the current sprite of the marking that is being applied
// - numDisplacements tracks how many displacements have been applied, and is
// an additional offset to ensure that the order of the base sprites is correct
// after inserting a displacement layer
// - The +1 ensures that markings render on top of the base organ
var spriteLayer = _sprite.AddLayer(target, sprite, index + i + numDisplacements + 1);
_sprite.LayerMapSet(target, layerId, spriteLayer);
_sprite.LayerSetSprite(target, layerId, rsi);
}
@ -206,7 +227,18 @@ public sealed partial class VisualBodySystem : SharedVisualBodySystem
_sprite.LayerSetColor(target, layerId, Color.White);
if (displacement != null && proto.CanBeDisplaced)
_displacement.TryAddDisplacement(displacement, (target, target.Comp), index + i + 1, layerId, out _);
{
_displacement.TryAddDisplacement(
displacement,
(target, target.Comp),
// Similar logic as above, but this makes the displacement layer go below the
// original sprite. So it should be all the displacements, then all the sprite layers on top
index + i + 1,
layerId,
out _
);
numDisplacements++;
}
}
applied.Add(marking);

View file

@ -11,10 +11,11 @@ namespace Content.Client.CardboardBox;
public sealed partial class CardboardBoxSystem : SharedCardboardBoxSystem
{
[Dependency] private EntityLookupSystem _entityLookup = default!;
[Dependency] private TransformSystem _transform = default!;
[Dependency] private ExamineSystemShared _examine = default!;
[Dependency] private SpriteSystem _sprite = default!;
[Dependency] private EntityQuery<MobStateComponent> _mobStateQuery = default!;
[Dependency] private TransformSystem _transform = default!;
[Dependency] private EntityQuery<MobStateComponent> _mobStateQuery;
public override void Initialize()
{
@ -33,13 +34,13 @@ public sealed partial class CardboardBoxSystem : SharedCardboardBoxSystem
var xform = Transform(source);
var sourcePos = _transform.GetMapCoordinates(source, xform);
//Any mob that can move should be surprised?
//God mind rework needs to come faster so it can just check for mind
//TODO: Replace with Mind Query when mind rework is in.
// Any mob that can move should be surprised?
// God mind rework needs to come faster so it can just check for mind
// TODO: Replace with Mind Query when mind rework is in.
var mobMoverEntities = new List<EntityUid>();
var mover = GetEntity(msg.Mover);
//Filter out entities in range to see that they're a mob and add them to the mobMoverEntities hash for faster lookup
// Filter out entities in range to see that they're a mob and add them to the mobMoverEntities hash for faster lookup
var movers = new HashSet<Entity<MobMoverComponent>>();
_entityLookup.GetEntitiesInRange(xform.Coordinates, box.Distance, movers);
@ -52,7 +53,7 @@ public sealed partial class CardboardBoxSystem : SharedCardboardBoxSystem
mobMoverEntities.Add(uid);
}
//Play the effect for the mobs as long as they can see the box and are in range.
// Play the effect for the mobs as long as they can see the box and are in range.
foreach (var mob in mobMoverEntities)
{
var mapPos = _transform.GetMapCoordinates(mob);
@ -64,13 +65,11 @@ public sealed partial class CardboardBoxSystem : SharedCardboardBoxSystem
continue;
var ent = Spawn(box.Effect, mapPos);
if (!TryComp(ent, out TransformComponent? entTransform) || !TryComp<SpriteComponent>(ent, out var sprite))
continue;
_sprite.SetOffset((ent, sprite), new Vector2(0, 1));
_transform.SetParent(ent, entTransform, mob);
}
}
}

View file

@ -1,6 +1,5 @@
using Content.Client.UserInterface.Fragments;
using Content.Client.UserInterface.Fragments;
using Content.Shared.CartridgeLoader;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
namespace Content.Client.CartridgeLoader;
@ -67,7 +66,7 @@ public abstract class CartridgeLoaderBoundUserInterface : BoundUserInterface
protected void ActivateCartridge(EntityUid cartridgeUid)
{
var message = new CartridgeLoaderUiMessage(_entManager.GetNetEntity(cartridgeUid), CartridgeUiMessageAction.Activate);
SendMessage(message);
SendPredictedMessage(message);
}
protected void DeactivateActiveCartridge()
@ -76,19 +75,19 @@ public abstract class CartridgeLoaderBoundUserInterface : BoundUserInterface
return;
var message = new CartridgeLoaderUiMessage(_entManager.GetNetEntity(_activeProgram.Value), CartridgeUiMessageAction.Deactivate);
SendMessage(message);
SendPredictedMessage(message);
}
protected void InstallCartridge(EntityUid cartridgeUid)
{
var message = new CartridgeLoaderUiMessage(_entManager.GetNetEntity(cartridgeUid), CartridgeUiMessageAction.Install);
SendMessage(message);
SendPredictedMessage(message);
}
protected void UninstallCartridge(EntityUid cartridgeUid)
{
var message = new CartridgeLoaderUiMessage(_entManager.GetNetEntity(cartridgeUid), CartridgeUiMessageAction.Uninstall);
SendMessage(message);
SendPredictedMessage(message);
}
private List<(EntityUid, CartridgeComponent)> GetCartridgeComponents(List<EntityUid> programs)

View file

@ -1,8 +0,0 @@
using Content.Shared.CartridgeLoader;
namespace Content.Client.CartridgeLoader;
public sealed class CartridgeLoaderSystem : SharedCartridgeLoaderSystem
{
//Empty client system for component replication
}

View file

@ -22,7 +22,7 @@ public sealed partial class LogProbeUi : UIFragment
{
var ev = new LogProbePrintMessage();
var message = new CartridgeUiMessage(ev);
ui.SendMessage(message);
ui.SendPredictedMessage(message);
};
}

View file

@ -1,5 +0,0 @@
using Content.Shared.CartridgeLoader.Cartridges;
namespace Content.Client.CartridgeLoader.Cartridges;
public sealed class NanoTaskCartridgeSystem : SharedNanoTaskCartridgeSystem;

View file

@ -1,8 +1,6 @@
using System.Linq;
using Content.Client.UserInterface.Fragments;
using Content.Shared.CartridgeLoader;
using Content.Shared.CartridgeLoader.Cartridges;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
namespace Content.Client.CartridgeLoader.Cartridges;
@ -44,7 +42,7 @@ public sealed partial class NanoTaskUi : UIFragment
if (_fragment.Tasks.Find(task => task.Id == id) is not NanoTaskItemAndId task)
return;
userInterface.SendMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskUpdateTask(new(id, new(
userInterface.SendPredictedMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskUpdateTask(new(id, new(
description: task.Data.Description,
taskIsFor: task.Data.TaskIsFor,
isTaskDone: !task.Data.IsTaskDone,
@ -53,22 +51,22 @@ public sealed partial class NanoTaskUi : UIFragment
};
_popup.TaskSaved += (id, data) =>
{
userInterface.SendMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskUpdateTask(new(id, data)))));
userInterface.SendPredictedMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskUpdateTask(new(id, data)))));
_popup.Close();
};
_popup.TaskDeleted += id =>
{
userInterface.SendMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskDeleteTask(id))));
userInterface.SendPredictedMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskDeleteTask(id))));
_popup.Close();
};
_popup.TaskCreated += data =>
{
userInterface.SendMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskAddTask(data))));
userInterface.SendPredictedMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskAddTask(data))));
_popup.Close();
};
_popup.TaskPrinted += data =>
{
userInterface.SendMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskPrintTask(data))));
userInterface.SendPredictedMessage(new CartridgeUiMessage(new NanoTaskUiMessageEvent(new NanoTaskPrintTask(data))));
};
}

View file

@ -1,7 +1,6 @@
using Content.Client.UserInterface.Fragments;
using Content.Shared.CartridgeLoader;
using Content.Shared.CartridgeLoader.Cartridges;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
namespace Content.Client.CartridgeLoader.Cartridges;
@ -34,6 +33,6 @@ public sealed partial class NotekeeperUi : UIFragment
{
var notekeeperMessage = new NotekeeperUiMessageEvent(action, note);
var message = new CartridgeUiMessage(notekeeperMessage);
userInterface.SendMessage(message);
userInterface.SendPredictedMessage(message);
}
}

View file

@ -0,0 +1,5 @@
using Content.Shared.Changeling.Systems;
namespace Content.Client.Changeling.Systems;
public sealed partial class ChangelingLastResortSystem : SharedChangelingLastResortSystem;

View file

@ -6,14 +6,17 @@ using Robust.Shared.Animations;
namespace Content.Client.Chasm;
/// <summary>
/// Handles the falling animation for entities that fall into a chasm.
/// Handles the falling animation for entities that fall into an entity with <see cref="ChasmComponent"/>.
/// </summary>
public sealed partial class ChasmFallingVisualsSystem : EntitySystem
{
[Dependency] private AnimationPlayerSystem _anim = default!;
[Dependency] private SpriteSystem _sprite = default!;
private readonly string _chasmFallAnimationKey = "chasm_fall";
[Dependency] private EntityQuery<AnimationPlayerComponent> _animationPlayerQuery;
[Dependency] private EntityQuery<SpriteComponent> _spriteQuery;
private const string ChasmFallAnimationKey = "chasm_fall";
public override void Initialize()
{
@ -23,60 +26,62 @@ public sealed partial class ChasmFallingVisualsSystem : EntitySystem
SubscribeLocalEvent<ChasmFallingComponent, ComponentRemove>(OnComponentRemove);
}
private void OnComponentInit(EntityUid uid, ChasmFallingComponent component, ComponentInit args)
private void OnComponentInit(Entity<ChasmFallingComponent> entity, ref ComponentInit args)
{
if (!TryComp<SpriteComponent>(uid, out var sprite) ||
TerminatingOrDeleted(uid))
if (!_spriteQuery.TryComp(entity, out var sprite) ||
TerminatingOrDeleted(entity))
{
return;
}
component.OriginalScale = sprite.Scale;
entity.Comp.OriginalScale = sprite.Scale;
if (!TryComp<AnimationPlayerComponent>(uid, out var player))
return;
if (_anim.HasRunningAnimation(player, _chasmFallAnimationKey))
return;
_anim.Play((uid, player), GetFallingAnimation(component), _chasmFallAnimationKey);
}
private void OnComponentRemove(EntityUid uid, ChasmFallingComponent component, ComponentRemove args)
{
if (!TryComp<SpriteComponent>(uid, out var sprite))
return;
_sprite.SetScale((uid, sprite), component.OriginalScale);
if (!TryComp<AnimationPlayerComponent>(uid, out var player))
return;
if (_anim.HasRunningAnimation(player, _chasmFallAnimationKey))
_anim.Stop((uid, player), _chasmFallAnimationKey);
}
private Animation GetFallingAnimation(ChasmFallingComponent component)
{
var length = component.AnimationTime;
return new Animation()
if (!_animationPlayerQuery.TryComp(entity, out var player) ||
_anim.HasRunningAnimation(player, ChasmFallAnimationKey))
{
Length = length,
return;
}
_anim.Play((entity, player), GetFallingAnimation(entity.Comp), ChasmFallAnimationKey);
}
private void OnComponentRemove(Entity<ChasmFallingComponent> entity, ref ComponentRemove args)
{
if (!_spriteQuery.TryComp(entity, out var sprite))
{
return;
}
_sprite.SetScale((entity, sprite), entity.Comp.OriginalScale);
if (!_animationPlayerQuery.TryComp(entity, out var player) ||
!_anim.HasRunningAnimation(player, ChasmFallAnimationKey))
{
return;
}
_anim.Stop((entity, player), ChasmFallAnimationKey);
}
private static Animation GetFallingAnimation(ChasmFallingComponent component)
{
return new Animation
{
Length = component.AnimationTime,
AnimationTracks =
{
new AnimationTrackComponentProperty()
new AnimationTrackComponentProperty
{
ComponentType = typeof(SpriteComponent),
Property = nameof(SpriteComponent.Scale),
KeyFrames =
{
new AnimationTrackProperty.KeyFrame(component.OriginalScale, 0.0f),
new AnimationTrackProperty.KeyFrame(component.AnimationScale, length.Seconds),
new AnimationTrackProperty.KeyFrame(component.AnimationScale, component.AnimationTime.Seconds),
},
InterpolationMode = AnimationInterpolationMode.Cubic
}
}
InterpolationMode = AnimationInterpolationMode.Cubic,
},
},
};
}
}

View file

@ -44,7 +44,8 @@
</BoxContainer>
<SplitContainer Orientation="Vertical"
HorizontalExpand="True"
VerticalExpand="True">
VerticalExpand="True"
MouseFilter="Ignore">
<ScrollContainer HScrollEnabled="False"
HorizontalExpand="True"
VerticalExpand="True"

View file

@ -13,13 +13,12 @@ namespace Content.Client.Crayon;
public sealed partial class CrayonSystem : SharedCrayonSystem
{
[Dependency] private SharedChargesSystem _charges = default!;
[Dependency] private EntityManager _entityManager = default!;
public override void Initialize()
{
base.Initialize();
Subs.ItemStatus<CrayonComponent>(ent => new StatusControl(ent, _charges, _entityManager));
Subs.ItemStatus<CrayonComponent>(ent => new StatusControl(ent, _charges, EntityManager));
}
private sealed class StatusControl : Control
@ -43,8 +42,8 @@ public sealed partial class CrayonSystem : SharedCrayonSystem
base.FrameUpdate(args);
_label.SetMarkup(Robust.Shared.Localization.Loc.GetString("crayon-drawing-label",
("color",_crayon.Comp.Color),
("state",_crayon.Comp.SelectedState),
("color", _crayon.Comp.Color),
("state", _crayon.Comp.SelectedState),
("charges", _charges.GetCurrentCharges(_crayon.Owner)),
("capacity", _capacity)));
}

View file

@ -1,4 +1,6 @@
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.DisplacementMap;
using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes;
@ -117,6 +119,13 @@ public sealed partial class DamageVisualsComponent : Component
/// </summary>
[DataField("damageOverlay")] public DamageVisualizerSprite? DamageOverlay;
/// <summary>
/// Applies a displacement map to the damage visuals.
/// Is set via <see cref="DamageableComponent.Displacement"/>.
/// </summary>
[DataField]
public DisplacementData? Displacement;
public readonly List<Enum> TargetLayerMapKeys = new();
public bool Disabled = false;
public bool Valid = true;

View file

@ -1,8 +1,10 @@
using System.Linq;
using Content.Client.DisplacementMap;
using Content.Shared.Damage;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Damage.Systems;
using Content.Shared.DisplacementMap;
using Content.Shared.FixedPoint;
using Robust.Client.GameObjects;
using Robust.Shared.Prototypes;
@ -29,6 +31,7 @@ namespace Content.Client.Damage;
public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisualsComponent>
{
[Dependency] private DamageableSystem _damageable = default!;
[Dependency] private DisplacementMapSystem _displacement = default!;
public override void Initialize()
{
@ -326,10 +329,14 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
/// </summary>
private void AddDamageLayerToSprite(Entity<SpriteComponent?> spriteEnt, DamageVisualizerSprite sprite, string state, string mapKey, int? index = null)
{
if (!Resolve(spriteEnt, ref spriteEnt.Comp))
return;
var newLayer = SpriteSystem.AddLayer(
spriteEnt,
new SpriteSpecifier.Rsi(
new(sprite.Sprite), state
new(sprite.Sprite),
state
),
index
);
@ -354,6 +361,12 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
if (damageVisComp.Disabled)
return;
if (AppearanceSystem.TryGetData<string>(uid, DamageVisualizerKeys.Displacement, out var displacement, args.Component) &&
ProtoMan.Resolve<DisplacementDataPrototype>(displacement, out var displacementProto))
damageVisComp.Displacement = displacementProto.Displacement;
else
damageVisComp.Displacement = null;
HandleDamage(uid, args.Component, damageVisComp);
}
@ -503,7 +516,7 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
}
else
{
UpdateOverlay((entity, spriteComponent), threshold);
UpdateOverlay((entity, spriteComponent), threshold, damageVisComp.Displacement);
}
}
@ -542,7 +555,7 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
}
else
{
UpdateOverlay((entity, spriteComponent, damageVisComp), damageGroup, threshold);
UpdateOverlay((entity, spriteComponent, damageVisComp), damageGroup, threshold, entity.Comp3.Displacement);
}
}
@ -614,7 +627,9 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
UpdateDamageLayerState(spriteEnt,
spriteLayer,
$"{layerState}",
threshold);
threshold,
$"{layerMapKey}trackDamage",
damageVisComp.Displacement);
}
}
else if (!damageVisComp.Overlay)
@ -625,7 +640,9 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
UpdateDamageLayerState(spriteEnt,
spriteLayer,
$"{layerState}",
threshold);
threshold,
$"{layerMapKey}",
damageVisComp.Displacement);
}
}
@ -648,7 +665,9 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
(entity, spriteComponent),
spriteLayer,
$"{layerState}_{damageGroup}",
threshold);
threshold,
$"{layerMapKey}{damageGroup}",
entity.Comp2.Displacement);
}
}
else if (!damageVisComp.Overlay)
@ -660,27 +679,31 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
(entity, spriteComponent),
spriteLayer,
$"{layerState}_{damageGroup}",
threshold);
threshold,
$"{layerMapKey}",
entity.Comp2.Displacement);
}
}
/// <summary>
/// Updates an overlay that is tracking all damage.
/// </summary>
private void UpdateOverlay(Entity<SpriteComponent> spriteEnt, FixedPoint2 threshold)
private void UpdateOverlay(Entity<SpriteComponent> spriteEnt, FixedPoint2 threshold, DisplacementData? displacement = null)
{
SpriteSystem.LayerMapTryGet(spriteEnt.AsNullable(), $"DamageOverlay", out var spriteLayer, false);
UpdateDamageLayerState(spriteEnt,
spriteLayer,
$"DamageOverlay",
threshold);
threshold,
$"DamageOverlay",
displacement);
}
/// <summary>
/// Updates an overlay based on damage group.
/// </summary>
private void UpdateOverlay(Entity<SpriteComponent, DamageVisualsComponent> entity, string damageGroup, FixedPoint2 threshold)
private void UpdateOverlay(Entity<SpriteComponent, DamageVisualsComponent> entity, string damageGroup, FixedPoint2 threshold, DisplacementData? displacement = null)
{
var spriteComponent = entity.Comp1;
var damageVisComp = entity.Comp2;
@ -695,7 +718,9 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
(entity, spriteComponent),
spriteLayer,
$"DamageOverlay_{damageGroup}",
threshold);
threshold,
$"DamageOverlay{damageGroup}",
displacement);
}
}
}
@ -706,7 +731,7 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
/// function calls it), and what threshold
/// was passed into it.
/// </summary>
private void UpdateDamageLayerState(Entity<SpriteComponent> spriteEnt, int spriteLayer, string statePrefix, FixedPoint2 threshold)
private void UpdateDamageLayerState(Entity<SpriteComponent> spriteEnt, int spriteLayer, string statePrefix, FixedPoint2 threshold, string layerKey, DisplacementData? displacement)
{
if (threshold == 0)
{
@ -719,6 +744,14 @@ public sealed partial class DamageVisualsSystem : VisualizerSystem<DamageVisuals
SpriteSystem.LayerSetVisible(spriteEnt.AsNullable(), spriteLayer, true);
}
SpriteSystem.LayerSetRsiState(spriteEnt.AsNullable(), spriteLayer, $"{statePrefix}_{threshold}");
if (displacement != null)
{
_displacement.TryAddDisplacement(displacement, spriteEnt, spriteLayer, layerKey, out _);
}
else
{
_displacement.EnsureDisplacementIsNotOnSprite(spriteEnt, layerKey);
}
}
}
}

View file

@ -41,7 +41,8 @@ public sealed partial class DisplacementMapSystem : EntitySystem
if (displacementKey is null)
return false;
EnsureDisplacementIsNotOnSprite(sprite, key);
if (EnsureDisplacementIsNotOnSprite(sprite, key))
index--;
if (data.ShaderOverride is not null)
{
@ -91,10 +92,21 @@ public sealed partial class DisplacementMapSystem : EntitySystem
var displacementLayer = _serialization.CreateCopy(displacementDataLayer, notNullableOverride: true);
// This previously assigned a string reading "this is impossible" if key.ToString eval'd to false.
// However, for the sake of sanity, we've changed this to assert non-null - !.
// If this throws an error, we're not sorry. Nanotrasen thanks you for your service fixing this bug.
displacementLayer.CopyToShaderParameters!.LayerKey = key.ToString()!;
if (key is Enum)
{
// We are doing this enum-to-string conversion here because CopyToShaderParameters.LayerKey only takes a string,
// but LayerMap keys are stored as objects, and therefore can take enums.
// There is a key parser in SpriteComponent but it requires the qualified (i.e. full) enum name.
// It feels like CopyToShaderParameters should be able to just take objects, but until then:
displacementLayer.CopyToShaderParameters!.LayerKey = $"enum.{key.GetType().Name}.{key}";
}
else
{
// This previously assigned a string reading "this is impossible" if key.ToString eval'd to false.
// However, for the sake of sanity, we've changed this to assert non-null - !.
// If this throws an error, we're not sorry. Nanotrasen thanks you for your service fixing this bug.
displacementLayer.CopyToShaderParameters!.LayerKey = key.ToString()!;
}
_sprite.AddLayer(sprite.AsNullable(), displacementLayer, index);
_sprite.LayerMapSet(sprite.AsNullable(), displacementKey, index);
@ -107,13 +119,13 @@ public sealed partial class DisplacementMapSystem : EntitySystem
/// </summary>
/// <param name="sprite">The sprite to remove the displacement layer from.</param>
/// <param name="key">The key of the layer that is referenced by the displacement layer we want to remove.</param>
/// <param name="logMissing">Whether to report an error if the displacement map isn't on the sprite.</param>
public void EnsureDisplacementIsNotOnSprite(Entity<SpriteComponent> sprite, object key)
/// <returns>Returns true if the displacement existed and was removed.</returns>
public bool EnsureDisplacementIsNotOnSprite(Entity<SpriteComponent> sprite, object key)
{
var displacementLayerKey = BuildDisplacementLayerKey(key);
if (displacementLayerKey is null)
return;
return false;
_sprite.RemoveLayer(sprite.AsNullable(), displacementLayerKey, false);
return _sprite.RemoveLayer(sprite.AsNullable(), displacementLayerKey, false);
}
}

View file

@ -44,7 +44,7 @@ public sealed partial class RainbowOverlay : Overlay
_statusEffects = _sysMan.GetEntitySystem<StatusEffectsSystem>();
_rainbowShader = _prototypeManager.Index(Shader).InstanceUnique();
_config.OnValueChanged(CCVars.ReducedMotion, OnReducedMotionChanged, invokeImmediately: true);
_config.OnValueChanged(CCVars.DisableRainbowOverlay, OnReducedMotionChanged, invokeImmediately: true);
}
private void OnReducedMotionChanged(bool reducedMotion)

View file

@ -50,7 +50,7 @@ public sealed partial class DrunkOverlay : Overlay
IoCManager.InjectDependencies(this);
_statusEffectsSystem = _entityManager.System<Shared.StatusEffectNew.StatusEffectsSystem>();
_drunkShader = _prototypeManager.Index(DrunkShader).InstanceUnique();
_configManager.OnValueChanged(CCVars.ReducedMotion, OnReducedMotionChanged, invokeImmediately: true);
_configManager.OnValueChanged(CCVars.DisableDrunkOverlay, OnReducedMotionChanged, invokeImmediately: true);
}
private void OnReducedMotionChanged(bool reducedMotion)

View file

@ -92,7 +92,7 @@ namespace Content.Client.Eye.Blinding
// While the cataracts shader is designed to be tame enough to keep motion sickness at bay, the general waviness means that those who are particularly sensitive to motion sickness will probably hurl.
// So the reasonable alternative here is to replace it with a static effect! Specifically, one that replicates the blindness effect seen across most SS13 servers.
if (_configManager.GetCVar(CCVars.ReducedMotion))
if (_configManager.GetCVar(CCVars.DisableBlurryVision))
{
_circleMaskShader.SetParameter("SCREEN_TEXTURE", ScreenTexture);
_circleMaskShader.SetParameter("Zoom", zoom);

View file

@ -42,8 +42,7 @@ public sealed partial class FaxVisualsSystem : EntitySystem
LayerKey = FaxMachineVisuals.VisualState,
KeyFrames =
{
new AnimationTrackSpriteFlick.KeyFrame(component.InsertingState, 0f),
new AnimationTrackSpriteFlick.KeyFrame("icon", 2.4f),
new AnimationTrackSpriteFlick.KeyFrame(component.InsertingState, 0f)
},
},
},

View file

@ -1,7 +1,7 @@
using Content.Shared.CCVar;
using Content.Shared.Flash;
using Content.Shared.Flash.Components;
using Content.Shared.StatusEffect;
using Content.Shared.StatusEffectNew;
using Robust.Client.Graphics;
using Robust.Client.Player;
using Robust.Shared.Configuration;
@ -9,101 +9,100 @@ using Robust.Shared.Enums;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
namespace Content.Client.Flash
namespace Content.Client.Flash;
public sealed partial class FlashOverlay : Overlay
{
public sealed partial class FlashOverlay : Overlay
private static readonly ProtoId<ShaderPrototype> FlashedEffectShader = "FlashedEffect";
[Dependency] private IPrototypeManager _prototypeManager = default!;
[Dependency] private IEntityManager _entityManager = default!;
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private IGameTiming _timing = default!;
[Dependency] private IConfigurationManager _configManager = default!;
private readonly StatusEffectsSystem _statusSys;
public override OverlaySpace Space => OverlaySpace.WorldSpace;
private readonly ShaderInstance _shader;
private bool _reducedMotion;
public float PercentComplete;
public Texture? ScreenshotTexture;
public FlashOverlay()
{
private static readonly ProtoId<ShaderPrototype> FlashedEffectShader = "FlashedEffect";
IoCManager.InjectDependencies(this);
_shader = _prototypeManager.Index(FlashedEffectShader).InstanceUnique();
_statusSys = _entityManager.System<StatusEffectsSystem>();
[Dependency] private IPrototypeManager _prototypeManager = default!;
[Dependency] private IEntityManager _entityManager = default!;
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private IGameTiming _timing = default!;
[Dependency] private IConfigurationManager _configManager = default!;
_configManager.OnValueChanged(CCVars.DisableFlashEffect, (b) => { _reducedMotion = b; }, invokeImmediately: true);
}
private readonly SharedFlashSystem _flash;
private readonly StatusEffectsSystem _statusSys;
protected override void FrameUpdate(FrameEventArgs args)
{
var playerEntity = _playerManager.LocalEntity;
public override OverlaySpace Space => OverlaySpace.WorldSpace;
private readonly ShaderInstance _shader;
private bool _reducedMotion;
public float PercentComplete;
public Texture? ScreenshotTexture;
if (playerEntity == null)
return;
public FlashOverlay()
if (!_statusSys.HasEffectComp<FlashedStatusEffectComponent>(playerEntity))
return;
if (!_statusSys.TryGetTime(playerEntity.Value, SharedFlashSystem.FlashedKey, out var time))
return;
var curTime = _timing.CurTime;
if (time.StartEffectTime == null || time.EndEffectTime == null)
return;
var lastsFor = (float)(time.EndEffectTime.Value - time.StartEffectTime.Value).TotalSeconds;
var timeDone = (float)(curTime - time.StartEffectTime.Value).TotalSeconds;
PercentComplete = timeDone / lastsFor;
}
protected override bool BeforeDraw(in OverlayDrawArgs args)
{
if (!_entityManager.TryGetComponent(_playerManager.LocalEntity, out EyeComponent? eyeComp))
return false;
if (args.Viewport.Eye != eyeComp.Eye)
return false;
return PercentComplete < 1.0f;
}
protected override void Draw(in OverlayDrawArgs args)
{
if (RequestScreenTexture && ScreenTexture != null)
{
IoCManager.InjectDependencies(this);
_shader = _prototypeManager.Index(FlashedEffectShader).InstanceUnique();
_flash = _entityManager.System<SharedFlashSystem>();
_statusSys = _entityManager.System<StatusEffectsSystem>();
_configManager.OnValueChanged(CCVars.ReducedMotion, (b) => { _reducedMotion = b; }, invokeImmediately: true);
ScreenshotTexture = ScreenTexture;
RequestScreenTexture = false; // we only need the first frame, so we can stop the request now for performance reasons
}
if (ScreenshotTexture == null)
return;
protected override void FrameUpdate(FrameEventArgs args)
var worldHandle = args.WorldHandle;
if (_reducedMotion)
{
var playerEntity = _playerManager.LocalEntity;
if (playerEntity == null)
return;
if (!_entityManager.HasComponent<FlashedComponent>(playerEntity)
|| !_entityManager.TryGetComponent<StatusEffectsComponent>(playerEntity, out var status))
return;
if (!_statusSys.TryGetTime(playerEntity.Value, _flash.FlashedKey, out var time, status))
return;
var curTime = _timing.CurTime;
var lastsFor = (float)(time.Value.Item2 - time.Value.Item1).TotalSeconds;
var timeDone = (float)(curTime - time.Value.Item1).TotalSeconds;
PercentComplete = timeDone / lastsFor;
// TODO: This is a very simple placeholder.
// Replace it with a proper shader once we come up with something good.
// Turns out making an effect that is supposed to be a bright, sudden, and disorienting flash
// not do any of that while also being equivalent in terms of game balance is hard.
var alpha = 1 - MathF.Pow(PercentComplete, 8f); // similar falloff curve to the flash shader
worldHandle.DrawRect(args.WorldBounds, new Color(0f, 0f, 0f, alpha));
}
protected override bool BeforeDraw(in OverlayDrawArgs args)
else
{
if (!_entityManager.TryGetComponent(_playerManager.LocalEntity, out EyeComponent? eyeComp))
return false;
if (args.Viewport.Eye != eyeComp.Eye)
return false;
return PercentComplete < 1.0f;
}
protected override void Draw(in OverlayDrawArgs args)
{
if (RequestScreenTexture && ScreenTexture != null)
{
ScreenshotTexture = ScreenTexture;
RequestScreenTexture = false; // we only need the first frame, so we can stop the request now for performance reasons
}
if (ScreenshotTexture == null)
return;
var worldHandle = args.WorldHandle;
if (_reducedMotion)
{
// TODO: This is a very simple placeholder.
// Replace it with a proper shader once we come up with something good.
// Turns out making an effect that is supposed to be a bright, sudden, and disorienting flash
// not do any of that while also being equivalent in terms of game balance is hard.
var alpha = 1 - MathF.Pow(PercentComplete, 8f); // similar falloff curve to the flash shader
worldHandle.DrawRect(args.WorldBounds, new Color(0f, 0f, 0f, alpha));
}
else
{
_shader.SetParameter("percentComplete", PercentComplete);
worldHandle.UseShader(_shader);
worldHandle.DrawTextureRectRegion(ScreenshotTexture, args.WorldBounds);
worldHandle.UseShader(null);
}
}
protected override void DisposeBehavior()
{
base.DisposeBehavior();
ScreenshotTexture = null;
_shader.SetParameter("percentComplete", PercentComplete);
worldHandle.UseShader(_shader);
worldHandle.DrawTextureRectRegion(ScreenshotTexture, args.WorldBounds);
worldHandle.UseShader(null);
}
}
protected override void DisposeBehavior()
{
base.DisposeBehavior();
ScreenshotTexture = null;
}
}

View file

@ -1,5 +1,6 @@
using Content.Shared.Flash;
using Content.Shared.Flash.Components;
using Content.Shared.StatusEffectNew;
using Robust.Client.Graphics;
using Robust.Client.Player;
using Robust.Shared.Player;
@ -10,49 +11,55 @@ public sealed partial class FlashSystem : SharedFlashSystem
{
[Dependency] private IPlayerManager _player = default!;
[Dependency] private IOverlayManager _overlayMan = default!;
[Dependency] private StatusEffectsSystem _statusEffects = default!;
private FlashOverlay _overlay = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<FlashedComponent, ComponentInit>(OnInit);
SubscribeLocalEvent<FlashedComponent, ComponentShutdown>(OnShutdown);
SubscribeLocalEvent<FlashedComponent, LocalPlayerAttachedEvent>(OnPlayerAttached);
SubscribeLocalEvent<FlashedComponent, LocalPlayerDetachedEvent>(OnPlayerDetached);
_overlay = new();
}
private void OnPlayerAttached(EntityUid uid, FlashedComponent component, LocalPlayerAttachedEvent args)
[SubscribeLocalEvent]
private void OnApplied(Entity<FlashedStatusEffectComponent> ent, ref StatusEffectAppliedEvent args)
{
_overlayMan.AddOverlay(_overlay);
}
private void OnPlayerDetached(EntityUid uid, FlashedComponent component, LocalPlayerDetachedEvent args)
{
_overlay.ScreenshotTexture = null;
_overlay.RequestScreenTexture = false;
_overlayMan.RemoveOverlay(_overlay);
}
private void OnInit(EntityUid uid, FlashedComponent component, ComponentInit args)
{
if (_player.LocalEntity == uid)
if (_player.LocalEntity == args.Target)
{
_overlay.RequestScreenTexture = true;
_overlayMan.AddOverlay(_overlay);
}
}
private void OnShutdown(EntityUid uid, FlashedComponent component, ComponentShutdown args)
[SubscribeLocalEvent]
private void OnRemoved(Entity<FlashedStatusEffectComponent> ent, ref StatusEffectRemovedEvent args)
{
if (_player.LocalEntity == uid)
if (_player.LocalEntity != args.Target)
return;
if (!_statusEffects.HasEffectComp<FlashedStatusEffectComponent>(args.Target))
{
_overlay.ScreenshotTexture = null;
_overlay.RequestScreenTexture = false;
_overlayMan.RemoveOverlay(_overlay);
}
}
[SubscribeLocalEvent]
private void OnPlayerAttached(Entity<FlashedStatusEffectComponent> ent, ref StatusEffectRelayedEvent<LocalPlayerAttachedEvent> args)
{
_overlay.RequestScreenTexture = true;
_overlayMan.AddOverlay(_overlay);
}
[SubscribeLocalEvent]
private void OnPlayerDetached(Entity<FlashedStatusEffectComponent> ent, ref StatusEffectRelayedEvent<LocalPlayerDetachedEvent> args)
{
if (_player.LocalEntity is null || _statusEffects.HasEffectComp<FlashedStatusEffectComponent>(_player.LocalEntity.Value))
return;
_overlay.ScreenshotTexture = null;
_overlay.RequestScreenTexture = false;
_overlayMan.RemoveOverlay(_overlay);
}
}

View file

@ -1,6 +1,8 @@
using Content.Client.Movement.Systems;
using Content.Shared.Actions;
using Content.Shared.Ghost;
using Content.Shared.NightVision;
using Content.Shared.Overlays;
using Robust.Client.Console;
using Robust.Client.GameObjects;
using Robust.Client.Player;
@ -13,9 +15,9 @@ namespace Content.Client.Ghost
[Dependency] private IClientConsoleHost _console = default!;
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private SharedActionsSystem _actions = default!;
[Dependency] private PointLightSystem _pointLightSystem = default!;
[Dependency] private ContentEyeSystem _contentEye = default!;
[Dependency] private SpriteSystem _sprite = default!;
[Dependency] private SharedNightVisionSystem _nv = default!;
public int AvailableGhostRoleCount { get; private set; }
@ -81,27 +83,25 @@ namespace Content.Client.Ghost
if (args.Handled)
return;
TryComp<PointLightComponent>(uid, out var light);
if (!component.DrawLight)
{
// normal lighting
Popup.PopupEntity(Loc.GetString("ghost-gui-toggle-lighting-manager-popup-normal"), args.Performer);
_contentEye.RequestEye(component.DrawFov, true);
}
else if (!light?.Enabled ?? false) // skip this option if we have no PointLightComponent
else if (TryComp<NightVisionComponent>(uid, out var nv) && !nv.Enabled)
{
// enable personal light
Popup.PopupEntity(Loc.GetString("ghost-gui-toggle-lighting-manager-popup-personal-light"), args.Performer);
_pointLightSystem.SetEnabled(uid, true, light);
Popup.PopupEntity(Loc.GetString("ghost-gui-toggle-lighting-manager-popup-half-bright"), args.Performer);
_nv.SetEnabled((uid, nv), true);
}
else
{
// fullbright mode
Popup.PopupEntity(Loc.GetString("ghost-gui-toggle-lighting-manager-popup-fullbright"), args.Performer);
_contentEye.RequestEye(component.DrawFov, false);
_pointLightSystem.SetEnabled(uid, false, light);
_nv.SetEnabled((uid, nv), false);
}
args.Handled = true;
}

View file

@ -36,7 +36,7 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
Container container = ReactantsContainer;
SetReagents(prototype.Reactants, ref container, protoMan);
Container productContainer = ProductsContainer;
var products = new Dictionary<string, FixedPoint2>(prototype.Products);
var products = new Dictionary<ProtoId<ReagentPrototype>, FixedPoint2>(prototype.Products);
foreach (var (reagent, reactantProto) in prototype.Reactants)
{
if (reactantProto.Catalyst)
@ -101,11 +101,11 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
ReactantsContainer.Visible = true;
ReactantsContainer.AddChild(label);
if (prototype.Reagent != null)
if (prototype.Reagent is {} reagent)
{
var quantity = new Dictionary<string, FixedPoint2>
var quantity = new Dictionary<ProtoId<ReagentPrototype>, FixedPoint2>
{
{ prototype.Reagent, FixedPoint2.New(0.21f) }
{ reagent, FixedPoint2.New(0.21f) }
};
Container productContainer = ProductsContainer;
SetReagents(quantity, ref productContainer, protoMan, false);
@ -113,9 +113,9 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
SetMixingCategory(categories, null, sysMan);
}
private void SetReagents(List<ReagentQuantity> reagents, ref Container container, IPrototypeManager protoMan, bool addLinks = true)
private static void SetReagents(List<ReagentQuantity> reagents, ref Container container, IPrototypeManager protoMan, bool addLinks = true)
{
var amounts = new Dictionary<string, FixedPoint2>();
var amounts = new Dictionary<ProtoId<ReagentPrototype>, FixedPoint2>();
foreach (var (reagent, quantity) in reagents)
{
amounts.Add(reagent.Prototype, quantity);
@ -123,13 +123,13 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
SetReagents(amounts, ref container, protoMan, addLinks);
}
private void SetReagents(
Dictionary<string, ReactantPrototype> reactants,
private static void SetReagents(
Dictionary<ProtoId<ReagentPrototype>, ReactantInfo> reactants,
ref Container container,
IPrototypeManager protoMan,
bool addLinks = true)
{
var amounts = new Dictionary<string, FixedPoint2>();
var amounts = new Dictionary<ProtoId<ReagentPrototype>, FixedPoint2>();
foreach (var (reagent, reactantPrototype) in reactants)
{
amounts.Add(reagent, reactantPrototype.Amount);
@ -137,22 +137,7 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
SetReagents(amounts, ref container, protoMan, addLinks);
}
[PublicAPI]
private void SetReagents(
Dictionary<ProtoId<MixingCategoryPrototype>, ReactantPrototype> reactants,
ref Container container,
IPrototypeManager protoMan,
bool addLinks = true)
{
var amounts = new Dictionary<string, FixedPoint2>();
foreach (var (reagent, reactantPrototype) in reactants)
{
amounts.Add(reagent, reactantPrototype.Amount);
}
SetReagents(amounts, ref container, protoMan, addLinks);
}
private void SetReagents(Dictionary<string, FixedPoint2> reagents, ref Container container, IPrototypeManager protoMan, bool addLinks = true)
private static void SetReagents(Dictionary<ProtoId<ReagentPrototype>, FixedPoint2> reagents, ref Container container, IPrototypeManager protoMan, bool addLinks = true)
{
foreach (var (product, amount) in reagents.OrderByDescending(p => p.Value))
{

View file

@ -13,6 +13,7 @@
<cc:VSeparator StyleClasses="LowDivider" Margin="0 -2"/>
</BoxContainer>
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<fancyTree:FancyTree Name="TableOfContents" HorizontalExpand="True"/>
<BoxContainer Name="SearchContainer" Visible="False" HorizontalExpand="True">
<LineEdit
Name="SearchBar"

View file

@ -1,4 +1,3 @@
using System.Diagnostics;
using System.Linq;
using Content.Client.Guidebook.RichText;
using Content.Client.UserInterface.ControlExtensions;
@ -21,11 +20,12 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
[Dependency] private DocumentParsingManager _parsingMan = default!;
[Dependency] private IResourceManager _resourceManager = default!;
private Dictionary<ProtoId<GuideEntryPrototype>, GuideEntry> _entries = new();
private Dictionary<ProtoId<GuideEntryPrototype>, GuideEntry> _entries = [];
private readonly ISawmill _sawmill;
public ProtoId<GuideEntryPrototype> LastEntry;
public ProtoId<GuideEntryPrototype>? Selected { get; private set; }
public GuidebookWindow()
{
@ -34,6 +34,7 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
_sawmill = Logger.GetSawmill("guidebook");
Tree.OnSelectedItemChanged += OnSelectionChanged;
TableOfContents.OnSelectedItemChanged += OnTableOfContentsSelectionChanged;
SearchBar.OnTextChanged += _ =>
{
@ -76,7 +77,7 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
UserInterfaceManager.DeferAction(() =>
{
if (control.GetControlScrollPosition() is not {} position)
if (control.GetControlScrollPosition() is not { } position)
return;
Scroll.HScrollTarget = position.X;
@ -91,6 +92,10 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
{
if (item != null && item.Metadata is GuideEntry entry)
{
// do nothing if the guide is the same as the currently selected one
if (entry.Id == Selected)
return;
ShowGuide(entry);
var isRulesEntry = entry.RuleEntry;
@ -101,12 +106,29 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
ClearSelectedGuide();
}
private void OnTableOfContentsSelectionChanged(TreeItem? item)
{
if (item is null || item.Metadata is not Label entry)
return;
UserInterfaceManager.DeferAction(() =>
{
if (entry.GetControlScrollPosition() is not { } position)
return;
Scroll.HScrollTarget = position.X;
Scroll.VScrollTarget = position.Y;
});
}
public void ClearSelectedGuide()
{
Placeholder.Visible = true;
EntryContainer.Visible = false;
SearchContainer.Visible = false;
EntryContainer.RemoveAllChildren();
Selected = null;
}
private void ShowGuide(GuideEntry entry)
@ -127,11 +149,11 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
_sawmill.Error($"Failed to parse contents of guide document {entry.Id}.");
}
LastEntry = entry.Id;
Selected = entry.Id;
var (linkableControls, linkControls) = GetLinkableControlsAndLinks(EntryContainer);
HashSet<IPrototype> availablePrototypeLinks = new();
HashSet<IPrototype> availablePrototypeLinks = [];
foreach (var linkableControl in linkableControls)
{
var prototype = linkableControl.RepresentedPrototype;
@ -145,48 +167,108 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
if (prototype != null && availablePrototypeLinks.Contains(prototype))
linkControl.EnablePrototypeLink();
}
RepopulateTableOfContents();
}
public void UpdateGuides(
private int? HeadingDepth(Label control)
{
if (control.StyleClasses.Contains("LabelHeadingBigger"))
return 1;
else if (control.StyleClasses.Contains("LabelHeading"))
return 2;
else if (control.StyleClasses.Contains("LabelKeyText"))
return 3;
return null;
}
private void RepopulateTableOfContents()
{
TableOfContents.Clear();
var firstEntry = TableOfContents.AddItem(null);
firstEntry.Label.Text = Loc.GetString("guidebook-toc-header");
var labels = EntryContainer.GetControlOfType<Label>(true);
var stack = new Stack<(TreeItem Item, int Depth)>();
foreach (var label in labels)
{
if (HeadingDepth(label) is not { } depth)
continue;
while (stack.TryPeek(out var previous) && previous.Depth >= depth)
{
stack.Pop();
}
var item = stack.TryPeek(out var parent)
? TableOfContents.AddItem(parent.Item)
: TableOfContents.AddItem(firstEntry);
item.Label.Text = label.Text;
item.Metadata = label;
stack.Push((item, depth));
}
// Expand all entries, but collapse the first one
TableOfContents.SetAllExpanded(true);
firstEntry.SetExpanded(false);
}
/// <summary>
/// Updates the guides used in the window.
/// Returns whether the guides changed.
/// </summary>
public bool UpdateGuides(
Dictionary<ProtoId<GuideEntryPrototype>, GuideEntry> entries,
List<ProtoId<GuideEntryPrototype>>? rootEntries = null,
ProtoId<GuideEntryPrototype>? forceRoot = null,
ProtoId<GuideEntryPrototype>? selected = null)
{
_entries = entries;
RepopulateTree(rootEntries, forceRoot);
ClearSelectedGuide();
Split.State = SplitContainer.SplitState.Auto;
if (entries.Count == 1)
// check if old and new entries are equal
var sameAsLastUpdate = entries.Count != _entries.Count || !entries.All(_entries.Contains);
if (sameAsLastUpdate)
{
TreeBox.Visible = false;
Split.ResizeMode = SplitContainer.SplitResizeMode.NotResizable;
selected = entries.Keys.First();
_entries = entries;
RepopulateTree(rootEntries, forceRoot);
Split.State = SplitContainer.SplitState.Auto;
if (entries.Count == 1)
{
TreeBox.Visible = false;
Split.ResizeMode = SplitContainer.SplitResizeMode.NotResizable;
selected = entries.Keys.First();
}
else
{
TreeBox.Visible = true;
Split.ResizeMode = SplitContainer.SplitResizeMode.RespectChildrenMinSize;
}
}
if (selected == null)
ClearSelectedGuide();
else
{
TreeBox.Visible = true;
Split.ResizeMode = SplitContainer.SplitResizeMode.RespectChildrenMinSize;
}
if (selected != null)
{
var item = Tree.Items.FirstOrDefault(x => x.Metadata is GuideEntry entry && entry.Id == selected);
Tree.SetSelectedIndex(item?.Index);
}
return sameAsLastUpdate;
}
private IEnumerable<GuideEntry> GetSortedEntries(List<ProtoId<GuideEntryPrototype>>? rootEntries)
{
if (rootEntries == null)
{
HashSet<ProtoId<GuideEntryPrototype>> entries = new(_entries.Keys);
HashSet<ProtoId<GuideEntryPrototype>> entries = [.. _entries.Keys];
foreach (var entry in _entries.Values)
{
entries.ExceptWith(entry.Children);
}
rootEntries = entries.ToList();
rootEntries = [.. entries];
}
// Only roots need to be sorted.
@ -203,7 +285,7 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
{
Tree.Clear();
HashSet<ProtoId<GuideEntryPrototype>> addedEntries = new();
HashSet<ProtoId<GuideEntryPrototype>> addedEntries = [];
var parent = forcedRoot == null ? null : AddEntry(forcedRoot.Value, null, addedEntries);
foreach (var entry in GetSortedEntries(roots))
@ -248,8 +330,6 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
private void HandleFilter()
{
var emptySearch = SearchBar.Text.Trim().Length == 0;
if (Tree.SelectedItem != null && Tree.SelectedItem.Metadata is GuideEntry entry && entry.FilterEnabled)
{
var foundElements = EntryContainer.GetSearchableControls();
@ -263,8 +343,8 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IA
private static (List<IPrototypeRepresentationControl>, List<IPrototypeLinkControl>) GetLinkableControlsAndLinks(Control parent)
{
List<IPrototypeRepresentationControl> linkableList = new();
List<IPrototypeLinkControl> linkList = new();
List<IPrototypeRepresentationControl> linkableList = [];
List<IPrototypeLinkControl> linkList = [];
foreach (var child in parent.Children)
{

View file

@ -107,7 +107,7 @@ public sealed partial class HolopadWindow : FancyWindow
if (!_accessReaderSystem.IsAllowed(player.Value, _owner.Value))
{
_popupSystem.PopupClient(Loc.GetString("holopad-window-access-denied"), _owner.Value, player.Value);
_popupSystem.PopupEntity(Loc.GetString("holopad-window-access-denied"), _owner.Value, player.Value);
return;
}

View file

@ -39,7 +39,7 @@ public sealed partial class InstrumentComponent : SharedInstrumentComponent
/// <summary>
/// Whether this instrument is handheld or not.
/// </summary>
[DataField("handheld")]
[DataField]
public bool Handheld { get; set; } // TODO: Replace this by simply checking if the entity has an ItemComponent.
/// <summary>

View file

@ -1,4 +1,5 @@
using Content.Client.Administration.Managers;
using Content.Client.Administration.Managers;
using Content.Client.Audio.Midi;
using Content.Client.Changelog;
using Content.Client.Chat.Managers;
using Content.Client.Clickable;
@ -69,6 +70,7 @@ namespace Content.Client.IoC
collection.Register<ClientsidePlaytimeTrackingManager>();
collection.Register<ClientFeedbackManager>();
collection.Register<ISharedFeedbackManager, ClientFeedbackManager>();
collection.Register<MidiFileCollectionManager>();
}
}
}

View file

@ -55,7 +55,7 @@ namespace Content.Client.Lobby.UI
_createNewCharacterButton.OnPressed += args =>
{
_preferencesManager.CreateCharacter(HumanoidCharacterProfile.Random());
_preferencesManager.CreateCharacter(HumanoidCharacterProfile.Random().WithJobFromCvar(_cfg));
ReloadCharacterPickers();
args.Event.Handle();
};
@ -66,6 +66,7 @@ namespace Content.Client.Lobby.UI
StatsButton.OnPressed += _ => new PlaytimeStatsWindow().OpenCentered();
_cfg.OnValueChanged(CCVars.SeeOwnNotes, p => AdminRemarksButton.Visible = p, true);
_cfg.OnValueChanged(CCVars.GameMaxCharacterSlots, _ => ReloadCharacterPickers());
// Corvax-Sponsors-Start
if (IoCManager.Instance!.TryResolveType<ISponsorWindowCreator>(out var creator))
{
@ -91,9 +92,11 @@ namespace Content.Client.Lobby.UI
return;
}
var maxCharactersSlots = _cfg.GetCVar(CCVars.GameMaxCharacterSlots);
_createNewCharacterButton.ToolTip =
Loc.GetString("character-setup-gui-create-new-character-button-tooltip",
("maxCharacters", _preferencesManager.Settings!.MaxCharacterSlots));
("maxCharacters", maxCharactersSlots));
var selectedSlot = _preferencesManager.Preferences?.SelectedCharacterIndex;
@ -106,6 +109,14 @@ namespace Content.Client.Lobby.UI
character,
slot == selectedSlot);
if (slot >= maxCharactersSlots)
{
characterPickerButton.SetOnlyStyleClass(ContainerButton.StylePseudoClassDisabled);
characterPickerButton.ToolTip =
Loc.GetString("character-setup-gui-create-new-character-button-tooltip",
("maxCharacters", maxCharactersSlots));
}
Characters.AddChild(characterPickerButton);
characterPickerButton.OnPressed += args =>
@ -119,7 +130,7 @@ namespace Content.Client.Lobby.UI
};
}
_createNewCharacterButton.Disabled = numberOfFullSlots >= _preferencesManager.Settings.MaxCharacterSlots;
_createNewCharacterButton.Disabled = numberOfFullSlots >= maxCharactersSlots;
Characters.AddChild(_createNewCharacterButton);
}
}

View file

@ -267,7 +267,7 @@ public sealed partial class HumanoidProfileEditor
private void SetVoice(ProtoId<EmoteSoundsPrototype> newVoice)
{
Profile = Profile?.WithVoice(newVoice);
IsDirty = true;
SetDirty();
}
private void SetGender(Gender newGender)

View file

@ -21,18 +21,15 @@ public sealed partial class HumanoidProfileEditor
NameEdit.Text = Profile?.Name ?? "";
}
private void RandomizeEverything()
/// <summary>
/// Randomize values selectively while respecting locked values.
/// </summary>
private void RandomizeProfile()
{
Profile = HumanoidCharacterProfile.Random();
Profile = Profile == null
? HumanoidCharacterProfile.Random()
: HumanoidCharacterProfile.Random(RandomizeLockButton.RandomizeCfg, Profile!);
SetProfile(Profile, CharacterSlot);
SetDirty();
}
private void RandomizeName()
{
if (Profile == null) return;
var name = HumanoidCharacterProfile.GetName(Profile.Species, Profile.Gender);
SetName(name);
UpdateNameEdit();
}
}

View file

@ -13,21 +13,34 @@
<BoxContainer Orientation="Vertical">
<ui:HighlightedContainer>
<BoxContainer Orientation="Vertical">
<ui:HighlightedContainer>
<BoxContainer Orientation="Vertical">
<BoxContainer Orientation="Horizontal" VerticalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-name-label'}" />
<LineEdit Name="NameEdit" MinSize="270 0" VerticalAlignment="Center" Margin="5 0 0 0" />
<Button Name="NameRandomize" Text="{Loc 'humanoid-profile-editor-name-random-button'}" />
</BoxContainer>
<Button Name="RandomizeEverythingButton" HorizontalAlignment="Center"
HorizontalExpand="False" MaxWidth="256"
Text="{Loc 'humanoid-profile-editor-randomize-everything-button'}" />
<RichTextLabel Name="WarningLabel" HorizontalExpand="False"
VerticalExpand="True" MaxWidth="425"
HorizontalAlignment="Left" />
</BoxContainer>
</ui:HighlightedContainer>
<BoxContainer Orientation="Horizontal" VerticalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-name-label'}" />
<LineEdit Name="NameEdit" MinSize="270 0" VerticalAlignment="Center" Margin="5 0 0 0" />
</BoxContainer>
<RichTextLabel Name="WarningLabel" HorizontalExpand="False"
VerticalExpand="True" MaxWidth="425"
HorizontalAlignment="Left" />
</BoxContainer>
</ui:HighlightedContainer>
</BoxContainer>
<!-- Randomizer -->
<BoxContainer Orientation="Vertical">
<ui:HighlightedContainer>
<BoxContainer Orientation="Vertical">
<Label Text="{Loc 'humanoid-profile-editor-randomize'}" HorizontalAlignment="Center"/>
<PanelContainer StyleClasses="LowDivider" Margin="0 10"/>
<GridContainer Columns="2" MinWidth="250" HorizontalExpand="True">
<ui:RandomizeLockButton For="Name" LabelText="{Loc 'humanoid-profile-editor-randomize-name-button'}"/>
<ui:RandomizeLockButton For="Species" LabelText="{Loc 'humanoid-profile-editor-randomize-species-button'}"/>
<ui:RandomizeLockButton For="Age" LabelText="{Loc 'humanoid-profile-editor-randomize-age-button'}"/>
<ui:RandomizeLockButton For="Sex" LabelText="{Loc 'humanoid-profile-editor-randomize-sex-button'}"/>
<ui:RandomizeLockButton For="Skin" LabelText="{Loc 'humanoid-profile-editor-randomize-skin-button'}"/>
<ui:RandomizeLockButton For="Eyes" LabelText="{Loc 'humanoid-profile-editor-randomize-eye-button'}"/>
</GridContainer>
<Button
Name="RandomizeUnlockedButton"
MaxWidth="256"
Text="{Loc 'humanoid-profile-editor-randomize-unlocked-button'}"/>
</BoxContainer>
</ui:HighlightedContainer>
</BoxContainer>

View file

@ -144,8 +144,7 @@ namespace Content.Client.Lobby.UI
NameEdit.OnTextChanged += args => { SetName(args.Text); };
NameEdit.IsValid = args => args.Length <= _maxNameLength;
NameRandomize.OnPressed += args => RandomizeName();
RandomizeEverythingButton.OnPressed += args => { RandomizeEverything(); };
RandomizeUnlockedButton.OnPressed += args => { RandomizeProfile(); };
WarningLabel.SetMarkup($"[color=red]{Loc.GetString("humanoid-profile-editor-naming-rules-warning")}[/color]");
#endregion Name

View file

@ -0,0 +1,17 @@
<BoxContainer
xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.Lobby.UI"
Orientation="Horizontal"
HorizontalExpand="True"
>
<TextureButton
Name="LockButton"
StyleIdentifier="RandomizerLockButton"
ToggleMode="True"
VerticalAlignment="Center"
Scale="0.3 0.3"
ToolTip="{Loc 'humanoid-profile-editor-randomizer-lock-tooltip'}"/>
<Label Name="LockName"/>
</BoxContainer>

View file

@ -0,0 +1,91 @@
using System.Numerics;
using Content.Client.Resources;
using Content.Client.Stylesheets;
using Content.Shared.Preferences;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using static Content.Client.Stylesheets.StylesheetHelpers;
namespace Content.Client.Lobby.UI;
/// <summary>
/// A toggle button with a lock icon meant to lock values for randomizing in the character editor screen.
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class RandomizeLockButton : BoxContainer
{
/// <summary>
/// Which value this button locks.
/// </summary>
public HumanoidCharacterProfile.RandomizeCfg For { get; set; }
/// <summary>
/// Name of the button
/// </summary>
public string? LabelText { get => LockName.Text; set => LockName.Text = value; }
/// <summary>
/// Tracks which values are allowed to be randomized.
/// </summary>
public static HumanoidCharacterProfile.RandomizeCfg RandomizeCfg = HumanoidCharacterProfile.RandomizeConfigAll;
public RandomizeLockButton()
{
RobustXamlLoader.Load(this);
// Ensure we reset this value to default every time the button is created
// Otherwise toggling, disconnecting and reconnecting will leave the cfg in an inconsistent state
RandomizeCfg = HumanoidCharacterProfile.RandomizeConfigAll;
LockButton.OnToggled += args => HandleToggle(args.Pressed);
}
private void HandleToggle(bool toggle)
{
if (For == default)
return;
if (toggle)
{
// if pressed, disable specific randomize by setting its bit to 0
RandomizeCfg &= ~For;
}
else
{
// if not pressed, enable specific randomize by setting its bit to 1
RandomizeCfg |= For;
}
}
}
[CommonSheetlet]
public sealed class RandomizeLockButtonStyles : Sheetlet<PalettedStylesheet>
{
public override StyleRule[] GetRules(PalettedStylesheet sheet, object config)
{
var textureLocked = ResCache.GetTexture("/Textures/Interface/VerbIcons/lock.svg.192dpi.png");
var textureUnlocked = ResCache.GetTexture("/Textures/Interface/VerbIcons/unlock.svg.192dpi.png");
return
[
E<TextureButton>()
.Identifier("RandomizerLockButton")
.Modulate(Color.FromHsl(new Vector4(0f, 0f, .65f, 1f)))
.Prop(TextureButton.StylePropertyTexture, textureUnlocked)
.Margin(new Thickness(0f, 0f, 3f, 0f)),
E<TextureButton>()
.Identifier("RandomizerLockButton")
.PseudoHovered()
.Modulate(Color.WhiteSmoke)
.Prop(TextureButton.StylePropertyTexture, textureLocked),
E<TextureButton>()
.Identifier("RandomizerLockButton")
.PseudoPressed()
.Modulate(Color.WhiteSmoke)
.Prop(TextureButton.StylePropertyTexture, textureLocked),
];
}
}

View file

@ -6,6 +6,7 @@ using Content.Client.UserInterface.Screens;
using Content.Client.UserInterface.Systems.Chat.Widgets;
using Content.Shared.Decals;
using Robust.Client.AutoGenerated;
using Robust.Client.Placement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
@ -18,6 +19,7 @@ namespace Content.Client.Mapping;
[GenerateTypedNameReferences]
public sealed partial class MappingScreen : InGameScreen
{
[Dependency] private IPlacementManager _placement = default!;
[Dependency] private IPrototypeManager _prototype = default!;
public DecalPlacementSystem DecalSystem = default!;
@ -89,9 +91,10 @@ public sealed partial class MappingScreen : InGameScreen
UpdateDecal();
};
for (var i = 0; i < EntitySpawnWindow.InitOpts.Length; i++)
var modes = _placement.AllModeNames;
for (var i = 0; i < modes.Length; i++)
{
EntityPlacementMode.AddItem(EntitySpawnWindow.InitOpts[i], i);
EntityPlacementMode.AddItem(modes[i], i);
}
Pick.Texture.TexturePath = "/Textures/Interface/eyedropper.svg.png";

View file

@ -560,7 +560,7 @@ public sealed partial class MappingState : GameplayStateBase
var placement = new PlacementInformation
{
PlacementOption = placementId > 0 ? EntitySpawnWindow.InitOpts[placementId] : entity.PlacementMode,
PlacementOption = placementId > 0 ? _placement.AllModeNames[placementId] : entity.PlacementMode,
EntityType = entity.ID,
IsTile = false
};
@ -659,7 +659,7 @@ public sealed partial class MappingState : GameplayStateBase
{
var placement = new PlacementInformation
{
PlacementOption = EntitySpawnWindow.InitOpts[args.Id],
PlacementOption = _placement.AllModeNames[args.Id],
EntityType = _placement.CurrentPermission!.EntityType,
TileType = _placement.CurrentPermission.TileType,
Range = 2,

View file

@ -0,0 +1,116 @@
using Content.Client.Overlays;
using Content.Shared.GameTicking;
using Content.Shared.NightVision;
using Content.Shared.Overlays;
using Robust.Client.Graphics;
using Robust.Client.Player;
using Robust.Shared.Player;
namespace Content.Client.NightVision;
/// <inheritdoc/>
public sealed partial class NightVisionSystem : SharedNightVisionSystem
{
[Dependency] private IOverlayManager _overlayMan = default!;
[Dependency] private IPlayerManager _player = default!;
private NightVisionOverlay _overlay = default!;
public override void Initialize()
{
base.Initialize();
_overlay = new NightVisionOverlay();
}
[SubscribeLocalEvent]
private void OnPlayerAttached(LocalPlayerAttachedEvent args)
{
RefreshOverlay(args.Entity);
}
[SubscribeLocalEvent]
private void OnPlayerDetached(LocalPlayerDetachedEvent args)
{
Deactivate(_player.LocalEntity);
}
[SubscribeLocalEvent]
private void OnHandleState(Entity<NightVisionComponent> ent, ref AfterAutoHandleStateEvent args)
{
RefreshOverlay(ent);
}
[SubscribeNetworkEvent]
private void OnRoundRestart(RoundRestartCleanupEvent args)
{
var localPlayer = _player.LocalSession?.AttachedEntity;
if (localPlayer != null)
Deactivate(localPlayer.Value);
}
private void Update(EntityUid entity, List<Entity<NightVisionComponent>> entities)
{
if (entity != _player.LocalSession?.AttachedEntity)
return;
// Find the component with the lowest noise.
NightVisionComponent? nvision = null;
var bestNoise = float.MaxValue;
foreach (var ent in entities)
{
if (!ent.Comp.Enabled)
continue;
if (ent.Comp.RelayOverlay == (ent.Owner == entity))
continue;
if (ent.Comp.Prioritized)
{
nvision = ent.Comp;
break;
}
var noise = ent.Comp.NoiseAmount * ent.Comp.NoiseMultiplier;
if (noise < bestNoise)
{
nvision = ent.Comp;
bestNoise = noise;
}
}
// There is no active night vision components, so we disable the overlay.
if (nvision == null)
{
Deactivate(entity);
return;
}
_overlay.SetParameters(nvision.OverlayColor, nvision.LightingColor, nvision.NoiseAmount, nvision.NoiseMultiplier);
if (!_overlayMan.HasOverlay<NightVisionOverlay>())
_overlayMan.AddOverlay(_overlay);
}
private void Deactivate(EntityUid? ent)
{
if (ent != _player.LocalSession?.AttachedEntity)
return;
_overlayMan.RemoveOverlay(_overlay);
}
protected override void RefreshOverlay(EntityUid target)
{
if (target != _player.LocalSession?.AttachedEntity)
return;
var ev = new RefreshNightVisionEvent();
RaiseLocalEvent(target, ref ev);
if (ev.Entities.Count > 0)
Update(target, ev.Entities);
else
Deactivate(target);
}
}

View file

@ -1,6 +1,8 @@
using Content.Client.DisplacementMap;
using Content.Shared.Nutrition.Components;
using Content.Shared.Nutrition.EntitySystems;
using Robust.Client.GameObjects;
using Robust.Shared.Prototypes;
namespace Content.Client.Nutrition.EntitySystems;
@ -8,6 +10,7 @@ public sealed partial class CreamPieSystem : SharedCreamPieSystem
{
[Dependency] private SpriteSystem _sprite = default!;
[Dependency] private AppearanceSystem _appearance = default!;
[Dependency] private DisplacementMapSystem _displacement = default!;
public override void Initialize()
{
@ -27,6 +30,7 @@ public sealed partial class CreamPieSystem : SharedCreamPieSystem
private void OnComponentShutdown(Entity<CreamPiedComponent> ent, ref ComponentShutdown args)
{
_sprite.RemoveLayer(ent.Owner, CreamPiedVisualLayer.Key);
_displacement.EnsureDisplacementIsNotOnSprite((ent, Comp<SpriteComponent>(ent)), CreamPiedVisualLayer.Key);
}
private void OnAppearanceChange(Entity<CreamPiedComponent> ent, ref AppearanceChangeEvent args)
@ -54,6 +58,7 @@ public sealed partial class CreamPieSystem : SharedCreamPieSystem
if (creamPied.Sprite == null)
{
_sprite.RemoveLayer((ent.Owner, sprite), CreamPiedVisualLayer.Key);
_displacement.EnsureDisplacementIsNotOnSprite((ent.Owner, sprite), CreamPiedVisualLayer.Key);
return;
}
@ -62,5 +67,10 @@ public sealed partial class CreamPieSystem : SharedCreamPieSystem
_appearance.TryGetData<bool>(ent.Owner, CreamPiedVisuals.Creamed, out var isCreamPied, appearance);
_sprite.LayerSetSprite((ent.Owner, sprite), index, creamPied.Sprite);
_sprite.LayerSetVisible((ent.Owner, sprite), index, isCreamPied);
if (ProtoMan.Resolve(ent.Comp1.Displacement, out var displacementProto))
{
_displacement.TryAddDisplacement(displacementProto.Displacement, (ent.Owner, sprite), index, CreamPiedVisualLayer.Key, out _);
}
}
}

View file

@ -4,10 +4,39 @@
<BoxContainer Orientation="Vertical">
<ScrollContainer VerticalExpand="True" HScrollEnabled="False">
<BoxContainer Orientation="Vertical" Margin="8">
<Label Text="{Loc 'ui-options-accessability-header-visuals'}"
<Label Text="{Loc 'ui-options-accessibility-header-reduced-motion'}"
StyleClasses="LabelKeyText"/>
<CheckBox Name="ReducedMotionCheckBox"
Text="{Loc 'ui-options-reduced-motion'}"
ToolTip="{Loc 'ui-options-reduced-motion-tooltip'}" />
<GridContainer Columns="2" HorizontalExpand="True" Margin="8 8 8 8">
<CheckBox Name="DisableAiStaticCheckBox"
Text="{Loc 'ui-options-disable-ai-static'}"
ToolTip="{Loc 'ui-options-disable-ai-static-tooltip'}" />
<CheckBox Name="DisableBlurryVisionCheckBox"
Text="{Loc 'ui-options-disable-blurry-vision'}"
ToolTip="{Loc 'ui-options-disable-blurry-vision-tooltip'}" />
<CheckBox Name="DisableDrunkOverlayCheckBox"
Text="{Loc 'ui-options-disable-drunk-overlay'}"
ToolTip="{Loc 'ui-options-disable-drunk-overlay-tooltip'}" />
<CheckBox Name="DisableFlashEffectCheckBox"
Text="{Loc 'ui-options-disable-flash-effect'}"
ToolTip="{Loc 'ui-options-disable-flash-effect-tooltip'}" />
<CheckBox Name="DisableHeatDistortionCheckBox"
Text="{Loc 'ui-options-disable-heat-distortion'}"
ToolTip="{Loc 'ui-options-disable-heat-distortion-tooltip'}" />
<CheckBox Name="DisableNightVisionNoiseCheckBox"
Text="{Loc 'ui-options-disable-nv-noise'}"
ToolTip="{Loc 'ui-options-disable-nv-noise-tooltip'}" />
<CheckBox Name="DisableRainbowOverlayCheckBox"
Text="{Loc 'ui-options-disable-rainbow-overlay'}"
ToolTip="{Loc 'ui-options-disable-rainbow-overlay-tooltip'}" />
<CheckBox Name="DisableSinguloWarpCheckBox"
Text="{Loc 'ui-options-disable-singulo-warp'}"
ToolTip="{Loc 'ui-options-disable-singulo-warp-tooltip'}" />
</GridContainer>
<Label Text="{Loc 'ui-options-accessibility-header-visuals'}"
StyleClasses="LabelKeyText"/>
<CheckBox Name="ReducedMotionCheckBox" Text="{Loc 'ui-options-reduced-motion'}" />
<CheckBox Name="DisableAiStaticCheckBox" Text="{Loc 'ui-options-disable-ai-static'}" />
<CheckBox Name="EnableColorNameCheckBox" Text="{Loc 'ui-options-enable-color-name'}" />
<CheckBox Name="ColorblindFriendlyCheckBox" Text="{Loc 'ui-options-colorblind-friendly'}" />
<ui:OptionSlider Name="ScreenShakeIntensitySlider" Title="{Loc 'ui-options-screen-shake-intensity'}" />
@ -19,7 +48,7 @@
<ui:OptionColorSlider Name="HighlightsColorSlider"
Title="{Loc 'ui-options-highlights-color'}"
Example="{Loc 'ui-options-highlights-color-example'}"/>
<Label Text="{Loc 'ui-options-accessability-header-content'}"
<Label Text="{Loc 'ui-options-accessibility-header-content'}"
StyleClasses="LabelKeyText"/>
<CheckBox Name="CensorNudityCheckBox" Text="{Loc 'ui-options-censor-nudity'}" />
</BoxContainer>

View file

@ -14,8 +14,17 @@ public sealed partial class AccessibilityTab : Control
Control.AddOptionCheckBox(CCVars.ChatEnableColorName, EnableColorNameCheckBox);
Control.AddOptionCheckBox(CCVars.AccessibilityColorblindFriendly, ColorblindFriendlyCheckBox);
Control.AddOptionCheckBox(CCVars.ReducedMotion, ReducedMotionCheckBox);
Control.AddOptionCheckBox(CCVars.DisableAiStatic, DisableAiStaticCheckBox);
Control.AddOptionCheckBox(CCVars.DisableBlurryVision, DisableBlurryVisionCheckBox);
Control.AddOptionCheckBox(CCVars.DisableDrunkOverlay, DisableDrunkOverlayCheckBox);
Control.AddOptionCheckBox(CCVars.DisableFlashEffect, DisableFlashEffectCheckBox);
Control.AddOptionCheckBox(CCVars.DisableHeatDistortion, DisableHeatDistortionCheckBox);
Control.AddOptionCheckBox(CCVars.DisableNightVisionNoise, DisableNightVisionNoiseCheckBox);
Control.AddOptionCheckBox(CCVars.DisableRainbowOverlay, DisableRainbowOverlayCheckBox);
Control.AddOptionCheckBox(CCVars.DisableSinguloWarp, DisableSinguloWarpCheckBox);
Control.AddOptionPercentSlider(CCVars.ScreenShakeIntensity, ScreenShakeIntensitySlider);
Control.AddOptionPercentSlider(CCVars.ChatWindowOpacity, ChatWindowOpacitySlider);
Control.AddOptionPercentSlider(CCVars.SpeechBubbleTextOpacity, SpeechBubbleTextOpacitySlider);

View file

@ -107,7 +107,7 @@ public sealed class EntityHealthBarOverlay : Overlay
var yOffset = bounds.Height * EyeManager.PixelsPerMeter / 2 - 3f;
var widthOfMob = bounds.Width * EyeManager.PixelsPerMeter;
var position = new Vector2(-widthOfMob / EyeManager.PixelsPerMeter / 2, yOffset / EyeManager.PixelsPerMeter);
var position = new Vector2(sprite.Offset.X - widthOfMob / EyeManager.PixelsPerMeter / 2, sprite.Offset.Y + yOffset / EyeManager.PixelsPerMeter);
var color = GetProgressColor(deathProgress.ratio, deathProgress.inCrit);
// Hardcoded width of the progress bar because it doesn't match the texture.

View file

@ -1,4 +1,6 @@
using Content.Shared.CCVar;
using Robust.Client.Graphics;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Prototypes;
@ -10,6 +12,7 @@ namespace Content.Client.Overlays;
public sealed partial class NightVisionOverlay : Overlay
{
[Dependency] private IPrototypeManager _prototypeManager = default!;
[Dependency] private IConfigurationManager _configManager = default!;
private static readonly ProtoId<ShaderPrototype> Shader = "NightVision";
private readonly ShaderInstance _nightVisionShader;
@ -19,6 +22,8 @@ public sealed partial class NightVisionOverlay : Overlay
public float NoiseAmount { get; private set; }
public float NoiseMultiplier { get; private set; }
public bool DisableNoise = false;
public override OverlaySpace Space => OverlaySpace.BeforeLighting | OverlaySpace.WorldSpace;
public override bool RequestScreenTexture => true;
@ -26,6 +31,12 @@ public sealed partial class NightVisionOverlay : Overlay
{
IoCManager.InjectDependencies(this);
_nightVisionShader = _prototypeManager.Index(Shader).InstanceUnique();
_configManager.OnValueChanged(CCVars.DisableNightVisionNoise, OnNightVisionNoiseChanged, invokeImmediately: true);
}
private void OnNightVisionNoiseChanged(bool toggle)
{
DisableNoise = toggle;
}
public void SetParameters(Color overlayColor, Color lightingColor, float noiseAmount, float noiseMultiplier)
@ -47,8 +58,8 @@ public sealed partial class NightVisionOverlay : Overlay
if (isSpace)
{
_nightVisionShader.SetParameter("SCREEN_TEXTURE", ScreenTexture);
_nightVisionShader.SetParameter("noise_amount", NoiseAmount);
_nightVisionShader.SetParameter("noise_multiplier", NoiseMultiplier);
_nightVisionShader.SetParameter("noise_amount", !DisableNoise ? NoiseAmount : 0);
_nightVisionShader.SetParameter("noise_multiplier", !DisableNoise ? NoiseMultiplier : 0);
handle.UseShader(_nightVisionShader);
}

View file

@ -1,70 +0,0 @@
using Content.Shared.Inventory.Events;
using Content.Shared.Overlays;
using Robust.Client.Graphics;
namespace Content.Client.Overlays;
/// <summary>
/// Shows/hides the <see cref="NightVisionOverlay"/> based on whether the observed
/// entity has a <see cref="NightVisionComponent"/> equipped.
/// </summary>
public sealed partial class NightVisionOverlaySystem : EquipmentHudSystem<NightVisionComponent>
{
[Dependency] private IOverlayManager _overlayMan = default!;
private NightVisionOverlay _overlay = default!;
public override void Initialize()
{
base.Initialize();
_overlay = new NightVisionOverlay();
SubscribeLocalEvent<NightVisionComponent, AfterAutoHandleStateEvent>(OnHandleState);
}
protected override void UpdateInternal(RefreshEquipmentHudEvent<NightVisionComponent> component)
{
base.UpdateInternal(component);
// Find the component with the lowest noise.
NightVisionComponent? nvision = null;
var bestNoise = float.MaxValue;
foreach (var comp in component.Components)
{
if (!comp.Enabled)
continue;
var noise = comp.NoiseAmount * comp.NoiseMultiplier;
if (noise < bestNoise)
{
nvision = comp;
bestNoise = noise;
}
}
// There is no active night vision components, so we disable the overlay.
if (nvision == null)
{
DeactivateInternal();
return;
}
_overlay.SetParameters(nvision.OverlayColor, nvision.LightingColor, nvision.NoiseAmount, nvision.NoiseMultiplier);
if (!_overlayMan.HasOverlay<NightVisionOverlay>())
_overlayMan.AddOverlay(_overlay);
}
protected override void DeactivateInternal()
{
base.DeactivateInternal();
_overlayMan.RemoveOverlay(_overlay);
}
private void OnHandleState(Entity<NightVisionComponent> ent, ref AfterAutoHandleStateEvent args)
{
RefreshOverlay();
}
}

View file

@ -48,12 +48,12 @@
</ContainerButton>
</BoxContainer>
<ScrollContainer HorizontalExpand="True" VerticalExpand="True" HScrollEnabled="True">
<BoxContainer Orientation="Vertical"
VerticalExpand="True"
HorizontalExpand="True"
MinSize="50 50"
Name="ProgramList"
Margin="4"/>
<GridContainer VerticalExpand="True"
HorizontalExpand="True"
MinSize="50 50"
Name="ProgramList"
Margin="4"
Columns="2" />
</ScrollContainer>
<ScrollContainer HorizontalExpand="True" VerticalExpand="True" HScrollEnabled="True">
<BoxContainer Orientation="Vertical"

View file

@ -200,10 +200,10 @@ namespace Content.Client.PDA
public void UpdateAvailablePrograms(List<(EntityUid, CartridgeComponent)> programs)
{
ProgramList.RemoveAllChildren();
if (programs.Count == 0)
{
ProgramList.RemoveAllChildren();
ProgramList.AddChild(new Label()
{
Text = Loc.GetString("comp-pda-io-no-programs-available"),
@ -215,50 +215,43 @@ namespace Content.Client.PDA
return;
}
var row = CreateProgramListRow();
var itemCount = 1;
ProgramList.AddChild(row);
foreach (var (uid, component) in programs)
if (ProgramList.ChildCount >= 1 && ProgramList.Children[0] is Label label)
{
//Create a new row every second program item starting from the first
if (itemCount % 2 != 0)
label.Orphan();
}
while (ProgramList.ChildCount > programs.Count)
{
ProgramList.Children[ProgramList.ChildCount - 1].Orphan();
}
for (var i = 0; i < programs.Count; i++)
{
var cartridge = programs[i];
if (i < ProgramList.ChildCount)
{
row = CreateProgramListRow();
ProgramList.AddChild(row);
var currentItem = ProgramList.Children[i];
if (currentItem is PdaProgramItem programItem)
{
programItem.SetCartridge(cartridge);
continue;
}
DebugTools.Assert(i == ProgramList.ChildCount-1);
currentItem.Orphan();
}
var item = new PdaProgramItem();
if (component.Icon is not null)
item.Icon.SetFromSpriteSpecifier(component.Icon);
item.OnPressed += _ => OnProgramItemPressed?.Invoke(uid);
switch (component.InstallationStatus)
{
case InstallationStatus.Cartridge:
item.InstallButton.Visible = true;
item.InstallButton.Text = Loc.GetString("cartridge-bound-user-interface-install-button");
item.InstallButton.OnPressed += _ => OnInstallButtonPressed?.Invoke(uid);
break;
case InstallationStatus.Installed:
item.InstallButton.Visible = true;
item.InstallButton.Text = Loc.GetString("cartridge-bound-user-interface-uninstall-button");
item.InstallButton.OnPressed += _ => OnUninstallButtonPressed?.Invoke(uid);
break;
}
item.ProgramName.Text = Loc.GetString(component.ProgramName);
item.SetHeight = 20;
row.AddChild(item);
itemCount++;
var item = new PdaProgramItem(cartridge);
item.OnProgramItemPressed += uid => OnProgramItemPressed?.Invoke(uid);
item.OnUninstallButtonPressed += uid => OnUninstallButtonPressed?.Invoke(uid);
item.OnInstallButtonPressed += uid => OnInstallButtonPressed?.Invoke(uid);
ProgramList.AddChild(item);
}
//Add a filler item to the last row when it only contains one item
if (itemCount % 2 == 0)
row.AddChild(new Control() { HorizontalExpand = true });
if (programs.Count % 2 == 0)
ProgramList.AddChild(new Control() { HorizontalExpand = true });
}
/// <summary>
@ -319,15 +312,6 @@ namespace Content.Client.PDA
_currentView = view;
}
private static BoxContainer CreateProgramListRow()
{
return new BoxContainer()
{
Orientation = BoxContainer.LayoutOrientation.Horizontal,
HorizontalExpand = true
};
}
private void HideAllViews()
{
var views = ViewContainer.Children;

View file

@ -1,17 +1,17 @@
<pda:PdaProgramItem HorizontalExpand="True" MinHeight="60" Margin="4"
xmlns:pda="clr-namespace:Content.Client.PDA"
xmlns="https://spacestation14.io">
<ContainerButton HorizontalExpand="True" MinHeight="60" Margin="4"
xmlns:pda="clr-namespace:Content.Client.PDA"
xmlns="https://spacestation14.io">
<PanelContainer Name="Panel"/>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" VerticalExpand="True">
<BoxContainer Orientation="Vertical" VerticalExpand="True" MinWidth="60">
<AnimatedTextureRect HorizontalAlignment="Center" VerticalAlignment="Center" VerticalExpand="True" Name="Icon" Access="Public"/>
<AnimatedTextureRect HorizontalAlignment="Center" VerticalAlignment="Center" VerticalExpand="True" Name="Icon" />
</BoxContainer>
<BoxContainer Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True">
<Label Name="ProgramName" Access="Public" VerticalExpand="True"/>
<Label Name="ProgramName" VerticalExpand="True" />
<BoxContainer HorizontalExpand="True" SetHeight="28" Margin="0 0 4 4">
<BoxContainer HorizontalExpand="True"/>
<Button Name="InstallButton" Access="Public" Visible="False" SetWidth="90" HorizontalAlignment="Right" VerticalExpand="True"></Button>
<BoxContainer HorizontalExpand="True" />
<Button Name="InstallButton" Visible="False" SetWidth="90" HorizontalAlignment="Right" VerticalExpand="True"></Button>
</BoxContainer>
</BoxContainer>
</BoxContainer>
</pda:PdaProgramItem>
</ContainerButton>

View file

@ -1,4 +1,5 @@
using Robust.Client.AutoGenerated;
using Content.Shared.CartridgeLoader;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
@ -12,6 +13,12 @@ public sealed partial class PdaProgramItem : ContainerButton
public const string NormalBgColor = "#313138";
public const string HoverColor = "#3E6C45";
private Entity<CartridgeComponent> _cartridge;
public event Action<EntityUid>? OnProgramItemPressed;
public event Action<EntityUid>? OnUninstallButtonPressed;
public event Action<EntityUid>? OnInstallButtonPressed;
private readonly StyleBoxFlat _styleBox = new()
{
BackgroundColor = Color.FromHex("#25252a"),
@ -23,10 +30,38 @@ public sealed partial class PdaProgramItem : ContainerButton
set => _styleBox.BackgroundColor = value;
}
public PdaProgramItem()
public PdaProgramItem(Entity<CartridgeComponent> program)
{
RobustXamlLoader.Load(this);
SetCartridge(program);
Panel.PanelOverride = _styleBox;
OnPressed += OnSelfPressed;
InstallButton.OnPressed += OnInstallPressed;
}
private void OnSelfPressed(ButtonEventArgs args)
{
OnProgramItemPressed?.Invoke(_cartridge);
}
private void OnInstallPressed(ButtonEventArgs args)
{
switch (_cartridge.Comp.InstallationStatus)
{
case InstallationStatus.Cartridge:
OnInstallButtonPressed?.Invoke(_cartridge.Owner);
break;
case InstallationStatus.Installed:
OnUninstallButtonPressed?.Invoke(_cartridge.Owner);
break;
case InstallationStatus.Readonly:
default:
break;
}
}
protected override void Draw(DrawingHandleScreen handle)
@ -35,6 +70,29 @@ public sealed partial class PdaProgramItem : ContainerButton
if (TryGetStyleProperty<Color>(StylePropertyBgColor, out var bgColor))
BackgroundColor = bgColor;
}
public void SetCartridge(Entity<CartridgeComponent> program)
{
if (program.Comp.Icon is { } icon)
Icon.SetFromSpriteSpecifier(icon);
_cartridge = program;
ProgramName.Text = Loc.GetString(program.Comp.ProgramName);
switch (program.Comp.InstallationStatus)
{
case InstallationStatus.Cartridge:
InstallButton.Visible = true;
InstallButton.Text = Loc.GetString("cartridge-bound-user-interface-install-button");
break;
case InstallationStatus.Installed:
InstallButton.Visible = true;
InstallButton.Text = Loc.GetString("cartridge-bound-user-interface-uninstall-button");
break;
case InstallationStatus.Readonly:
InstallButton.Visible = false;
break;
}
}
}

View file

@ -107,10 +107,12 @@ public sealed partial class PaperVisualsComponent : Component
public int ContentImageNumLines = 1;
/// <summary>
/// Modulate the style's font by this color
/// Optional default color for text written on the paper. Useful to
/// specify light color text for use on dark backgrounds. Paper text
/// can still use markup to override color for subsections.
/// </summary>
[DataField]
public Color FontAccentColor = new Color(223, 223, 213);
public Color? DefaultTextColor = null;
/// <summary>
/// This can enforce that your paper has a limited area to write in.

View file

@ -23,7 +23,9 @@ namespace Content.Client.Paper.UI
private static Color DefaultTextColor = new(25, 25, 25);
// <summary>
// Default color for text which hasn't been changed using markup
private Color _writtenTextColor = DefaultTextColor;
// Size of resize handles around the paper
private const int DRAG_MARGIN_SIZE = 16;
@ -152,8 +154,7 @@ namespace Content.Client.Paper.UI
visuals.FooterMargin.Right, visuals.FooterMargin.Bottom);
PaperContent.ModulateSelfOverride = visuals.ContentImageModulate;
WrittenTextLabel.ModulateSelfOverride = visuals.FontAccentColor;
FillStatus.ModulateSelfOverride = visuals.FontAccentColor;
_writtenTextColor = visuals.DefaultTextColor ?? DefaultTextColor;
var contentImage = visuals.ContentImagePath != null ? _resCache.GetResource<TextureResource>(visuals.ContentImagePath) : null;
if (contentImage != null)
@ -271,7 +272,7 @@ namespace Content.Client.Paper.UI
{
msg.AddMarkupPermissive("\r\n");
}
WrittenTextLabel.SetMessage(msg, UserFormattableTags.BaseAllowedTags, DefaultTextColor);
WrittenTextLabel.SetMessage(msg, UserFormattableTags.BaseAllowedTags, _writtenTextColor);
WrittenTextLabel.Visible = !isEditing && state.Text.Length > 0;
BlankPaperIndicator.Visible = !isEditing && state.Text.Length == 0;

View file

@ -12,358 +12,369 @@ using Robust.Shared.Configuration;
using Robust.Shared.Map;
using Robust.Shared.Player;
using Robust.Shared.Replays;
using Robust.Shared.Timing;
namespace Content.Client.Popups
namespace Content.Client.Popups;
public sealed partial class PopupSystem : SharedPopupSystem
{
public sealed partial class PopupSystem : SharedPopupSystem
[Dependency] private IConfigurationManager _configManager = default!;
[Dependency] private IInputManager _inputManager = default!;
[Dependency] private IOverlayManager _overlay = default!;
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private IUserInterfaceManager _uiManager = default!;
[Dependency] private IReplayRecordingManager _replayRecording = default!;
[Dependency] private ExamineSystemShared _examine = default!;
[Dependency] private SharedTransformSystem _transform = default!;
public IReadOnlyCollection<WorldPopupLabel> WorldLabels => _aliveWorldLabels.Values;
public IReadOnlyCollection<CursorPopupLabel> CursorLabels => _aliveCursorLabels.Values;
private readonly Dictionary<WorldPopupData, WorldPopupLabel> _aliveWorldLabels = new();
private readonly Dictionary<CursorPopupData, CursorPopupLabel> _aliveCursorLabels = new();
/// <summary>
/// List of popups that have been predicted by the client.
/// If a popup is received from the server that matches one of these, it will be ignored to prevent duplicates.
/// </summary>
private readonly List<IPopupPredictionInstance> _predictionInstances = new();
public const float MinimumPopupLifetime = 0.7f;
public const float MaximumPopupLifetime = 5f;
public const float PopupLifetimePerCharacter = 0.04f;
public override void Initialize()
{
[Dependency] private IConfigurationManager _configManager = default!;
[Dependency] private IInputManager _inputManager = default!;
[Dependency] private IOverlayManager _overlay = default!;
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private IGameTiming _timing = default!;
[Dependency] private IUserInterfaceManager _uiManager = default!;
[Dependency] private IReplayRecordingManager _replayRecording = default!;
[Dependency] private ExamineSystemShared _examine = default!;
[Dependency] private SharedTransformSystem _transform = default!;
base.Initialize();
public IReadOnlyCollection<WorldPopupLabel> WorldLabels => _aliveWorldLabels.Values;
public IReadOnlyCollection<CursorPopupLabel> CursorLabels => _aliveCursorLabels.Values;
private readonly Dictionary<WorldPopupData, WorldPopupLabel> _aliveWorldLabels = new();
private readonly Dictionary<CursorPopupData, CursorPopupLabel> _aliveCursorLabels = new();
public const float MinimumPopupLifetime = 0.7f;
public const float MaximumPopupLifetime = 5f;
public const float PopupLifetimePerCharacter = 0.04f;
public override void Initialize()
{
SubscribeNetworkEvent<PopupCursorEvent>(OnPopupCursorEvent);
SubscribeNetworkEvent<PopupCoordinatesEvent>(OnPopupCoordinatesEvent);
SubscribeNetworkEvent<PopupEntityEvent>(OnPopupEntityEvent);
SubscribeNetworkEvent<RoundRestartCleanupEvent>(OnRoundRestart);
_overlay
.AddOverlay(new PopupOverlay(
_configManager,
EntityManager,
_playerManager,
ProtoMan,
_uiManager,
_uiManager.GetUIController<PopupUIController>(),
_examine,
_transform,
this));
}
public override void Shutdown()
{
base.Shutdown();
_overlay
.RemoveOverlay<PopupOverlay>();
}
private void WrapAndRepeatPopup(PopupLabel existingLabel, string popupMessage)
{
existingLabel.TotalTime = 0;
existingLabel.Repeats += 1;
existingLabel.Text = Loc.GetString("popup-system-repeated-popup-stacking-wrap",
("popup-message", popupMessage),
("count", existingLabel.Repeats));
}
private void PopupMessage(string? message, PopupType type, EntityCoordinates coordinates, EntityUid? entity, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
{
if (entity != null)
_replayRecording.RecordClientMessage(new PopupEntityEvent(message, type, GetNetEntity(entity.Value)));
else
_replayRecording.RecordClientMessage(new PopupCoordinatesEvent(message, type, GetNetCoordinates(coordinates)));
}
var popupData = new WorldPopupData(message, type, coordinates, entity);
if (_aliveWorldLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new WorldPopupLabel(coordinates)
{
Text = message,
Type = type,
};
_aliveWorldLabels.Add(popupData, label);
}
#region Abstract Method Implementations
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small)
{
PopupMessage(message, type, coordinates, null, true);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupMessage(message, type, coordinates, null, true);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupMessage(message, type, coordinates, null, true);
}
public override void PopupPredictedCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupCoordinates(message, coordinates, recipient.Value, type);
}
private void PopupCursorInternal(string? message, PopupType type, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
_replayRecording.RecordClientMessage(new PopupCursorEvent(message, type));
var popupData = new CursorPopupData(message, type);
if (_aliveCursorLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new CursorPopupLabel(_inputManager.MouseScreenPosition)
{
Text = message,
Type = type,
};
_aliveCursorLabels.Add(popupData, label);
}
public override void PopupCursor(string? message, PopupType type = PopupType.Small)
{
if (!_timing.IsFirstTimePredicted)
return;
PopupCursorInternal(message, type, true);
}
public override void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupCursor(message, type);
}
public override void PopupCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupCursor(message, type);
}
public override void PopupPredictedCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
PopupCursor(message, recipient, type);
}
public override void PopupPredictedCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small)
{
PopupCursor(message, recipient, type);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool replayRecord, PopupType type = PopupType.Small)
{
PopupCoordinates(message, coordinates, type);
}
public override void PopupEntity(string? message, EntityUid uid, EntityUid recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (!filter.Recipients.Contains(_playerManager.LocalSession))
return;
PopupEntity(message, uid, type);
}
public override void PopupClient(string? message, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient == null)
return;
if (_timing.IsFirstTimePredicted)
PopupCursor(message, recipient.Value, type);
}
public override void PopupClient(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient == null)
return;
if (_timing.IsFirstTimePredicted)
PopupEntity(message, uid, recipient.Value, type);
}
public override void PopupClient(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient == null)
return;
if (_timing.IsFirstTimePredicted)
PopupCoordinates(message, coordinates, recipient.Value, type);
}
public override void PopupEntity(string? message, EntityUid uid, PopupType type = PopupType.Small)
{
if (TryComp(uid, out TransformComponent? transform))
PopupMessage(message, type, transform.Coordinates, uid, true);
}
public override void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupEntity(message, uid, recipient.Value, type);
}
public override void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupEntity(message, uid, recipient.Value, type);
}
public override void PopupPredicted(string? recipientMessage, string? othersMessage, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupEntity(recipientMessage, uid, recipient.Value, type);
}
#endregion
#region Network Event Handlers
private void OnPopupCursorEvent(PopupCursorEvent ev)
{
PopupCursorInternal(ev.Message, ev.Type, false);
}
private void OnPopupCoordinatesEvent(PopupCoordinatesEvent ev)
{
PopupMessage(ev.Message, ev.Type, GetCoordinates(ev.Coordinates), null, false);
}
private void OnPopupEntityEvent(PopupEntityEvent ev)
{
var entity = GetEntity(ev.Uid);
if (TryComp(entity, out TransformComponent? transform))
PopupMessage(ev.Message, ev.Type, transform.Coordinates, entity, false);
}
private void OnRoundRestart(RoundRestartCleanupEvent ev)
{
_aliveCursorLabels.Clear();
_aliveWorldLabels.Clear();
}
#endregion
public static float GetPopupLifetime(PopupLabel label)
{
return Math.Clamp(PopupLifetimePerCharacter * label.Text.Length,
MinimumPopupLifetime,
MaximumPopupLifetime);
}
public override void FrameUpdate(float frameTime)
{
if (_aliveWorldLabels.Count == 0 && _aliveCursorLabels.Count == 0)
return;
if (_aliveWorldLabels.Count > 0)
{
var aliveWorldToRemove = new ValueList<WorldPopupData>();
foreach (var (data, label) in _aliveWorldLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label) || Deleted(label.InitialPos.EntityId))
{
aliveWorldToRemove.Add(data);
}
}
foreach (var data in aliveWorldToRemove)
{
_aliveWorldLabels.Remove(data);
}
}
if (_aliveCursorLabels.Count > 0)
{
var aliveCursorToRemove = new ValueList<CursorPopupData>();
foreach (var (data, label) in _aliveCursorLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label))
{
aliveCursorToRemove.Add(data);
}
}
foreach (var data in aliveCursorToRemove)
{
_aliveCursorLabels.Remove(data);
}
}
}
public abstract class PopupLabel
{
public PopupType Type = PopupType.Small;
public string Text { get; set; } = string.Empty;
public float TotalTime { get; set; }
public int Repeats = 1;
}
public sealed class WorldPopupLabel(EntityCoordinates coordinates) : PopupLabel
{
/// <summary>
/// The original EntityCoordinates of the label.
/// </summary>
public EntityCoordinates InitialPos = coordinates;
}
public sealed class CursorPopupLabel(ScreenCoordinates screenCoords) : PopupLabel
{
public ScreenCoordinates InitialPos = screenCoords;
}
[UsedImplicitly]
private record struct WorldPopupData(
string Message,
PopupType Type,
EntityCoordinates Coordinates,
EntityUid? Entity);
[UsedImplicitly]
private record struct CursorPopupData(
string Message,
PopupType Type);
_overlay.AddOverlay(new PopupOverlay(
_configManager,
EntityManager,
_playerManager,
ProtoMan,
_uiManager,
_uiManager.GetUIController<PopupUIController>(),
_examine,
_transform,
this));
}
public override void Shutdown()
{
base.Shutdown();
_overlay.RemoveOverlay<PopupOverlay>();
}
/// <summary>
/// If the same popup is repeated, this will make show x2, x3, x4, ... at the end of the message instead of creating a new, overlapping popup.
/// </summary>
private void WrapAndRepeatPopup(PopupLabel existingLabel, string popupMessage)
{
existingLabel.TotalTime = 0;
existingLabel.Repeats += 1;
existingLabel.Text = Loc.GetString("popup-system-repeated-popup-stacking-wrap",
("popup-message", popupMessage),
("count", existingLabel.Repeats));
}
/// <summary>
/// Interal implementation for both coordinates and entity popups.
/// </summary>
private void PopupInternal(string? message, PopupType type, EntityCoordinates coordinates, EntityUid? entity, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
{
if (entity != null)
_replayRecording.RecordClientMessage(new PopupEntityEvent(message, type, Timing.CurTick, GetNetEntity(entity.Value)));
else
_replayRecording.RecordClientMessage(new PopupCoordinatesEvent(message, type, Timing.CurTick, GetNetCoordinates(coordinates), 0));
}
var popupData = new WorldPopupData(message, type, coordinates, entity);
if (_aliveWorldLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new WorldPopupLabel(coordinates)
{
Text = message,
Type = type,
};
_aliveWorldLabels.Add(popupData, label);
}
/// <summary>
/// Internal implementation for cursor popups.
/// </summary>
private void PopupCursorInternal(string? message, PopupType type, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
_replayRecording.RecordClientMessage(new PopupCursorEvent(message, type, Timing.CurTick));
var popupData = new CursorPopupData(message, type);
if (_aliveCursorLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new CursorPopupLabel(_inputManager.MouseScreenPosition)
{
Text = message,
Type = type,
};
_aliveCursorLabels.Add(popupData, label);
}
#region Abstract Method Implementations
/// <summary>
/// Shows a popup at the local user's cursor.
/// </summary>
/// <remarks>
/// This overload only exists on the client. If you want to use this in Shared you should use the overload that takes a recipient, session or filter instead.
/// We do not add a virtual method to the shared system because that will cause problems if the shared code is run in a non-predicted way.
/// </remarks>
/// <param name="message">The message to display.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public void PopupCursor(string? message, PopupType type = PopupType.Small)
{
if (!Timing.IsFirstTimePredicted || message is null)
return;
_predictionInstances.Add(new PopupCursorEvent.PredictionInstance(message, type, Timing.CurTick));
PopupCursorInternal(message, type, true);
}
public override void PopupCursor(string? message, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupCursor(message, type);
}
public override void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupCursor(message, type);
}
public override void PopupCursor(string? message, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (filter.Recipients.Contains(_playerManager.LocalSession))
PopupCursor(message, type);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (!Timing.IsFirstTimePredicted || message is null)
return;
_predictionInstances.Add(new PopupCoordinatesEvent.PredictionInstance(message, type, Timing.CurTick, predictionKey));
PopupInternal(message, type, coordinates, null, true);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (_playerManager.LocalSession == recipient)
PopupCoordinates(message, coordinates, type, predictionKey);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (_playerManager.LocalEntity == recipient)
PopupCoordinates(message, coordinates, type, predictionKey);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool replayRecord, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (filter.Recipients.Contains(_playerManager.LocalSession))
PopupCoordinates(message, coordinates, type, predictionKey);
}
public override void PopupEntity(string? message, EntityUid uid, PopupType type = PopupType.Small)
{
if (!Timing.IsFirstTimePredicted || message is null)
return;
if (!TryComp(uid, out TransformComponent? transform))
return;
_predictionInstances.Add(new PopupEntityEvent.PredictionInstance(message, type, Timing.CurTick, GetNetEntity(uid)));
PopupInternal(message, type, transform.Coordinates, uid, true);
}
public override void PopupEntity(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (filter.Recipients.Contains(_playerManager.LocalSession))
PopupEntity(message, uid, type);
}
#endregion
#region Network Event Handlers
[SubscribeNetworkEvent]
private void OnPopupCursorEvent(PopupCursorEvent ev)
{
var instance = new PopupCursorEvent.PredictionInstance(ev.Message, ev.Type, ev.Tick);
if (_predictionInstances.Remove(instance))
return;
PopupCursorInternal(ev.Message, ev.Type, false);
}
[SubscribeNetworkEvent]
private void OnPopupCoordinatesEvent(PopupCoordinatesEvent ev)
{
var instance = new PopupCoordinatesEvent.PredictionInstance(ev.Message, ev.Type, ev.Tick, ev.PredictionKey);
if (_predictionInstances.Remove(instance))
return;
PopupInternal(ev.Message, ev.Type, GetCoordinates(ev.Coordinates), null, false);
}
[SubscribeNetworkEvent]
private void OnPopupEntityEvent(PopupEntityEvent ev)
{
var instance = new PopupEntityEvent.PredictionInstance(ev.Message, ev.Type, ev.Tick, ev.Uid);
if (_predictionInstances.Remove(instance))
return;
var entity = GetEntity(ev.Uid);
if (TryComp(entity, out TransformComponent? transform))
PopupInternal(ev.Message, ev.Type, transform.Coordinates, entity, false);
}
[SubscribeNetworkEvent]
private void OnRoundRestart(RoundRestartCleanupEvent ev)
{
_aliveCursorLabels.Clear();
_aliveWorldLabels.Clear();
_predictionInstances.Clear();
}
#endregion
/// <summary>
/// Calculates the lifetime of a popup based on its text length.
/// </summary>
public static float GetPopupLifetime(PopupLabel label)
{
return Math.Clamp(PopupLifetimePerCharacter * label.Text.Length,
MinimumPopupLifetime,
MaximumPopupLifetime);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
if (!Timing.IsFirstTimePredicted)
return; // We only need to clean up once per tick.
// We only keep track of prediction instances for a short amount of time to prevent memory leaks.
// We can safely assume that if a popup hasn't been received from the server within a 10 seconds, it will never be received.
var deleteTickCount = Timing.TickRate * 10;
if (_predictionInstances.Count != 0)
{
_predictionInstances.RemoveAll(p => (int)Timing.CurTick.Value - (int)p.Tick.Value > deleteTickCount);
}
}
public override void FrameUpdate(float frameTime)
{
if (_aliveWorldLabels.Count == 0 && _aliveCursorLabels.Count == 0)
return;
if (_aliveWorldLabels.Count > 0)
{
var aliveWorldToRemove = new ValueList<WorldPopupData>();
foreach (var (data, label) in _aliveWorldLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label) || Deleted(label.InitialPos.EntityId))
{
aliveWorldToRemove.Add(data);
}
}
foreach (var data in aliveWorldToRemove)
{
_aliveWorldLabels.Remove(data);
}
}
if (_aliveCursorLabels.Count > 0)
{
var aliveCursorToRemove = new ValueList<CursorPopupData>();
foreach (var (data, label) in _aliveCursorLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label))
{
aliveCursorToRemove.Add(data);
}
}
foreach (var data in aliveCursorToRemove)
{
_aliveCursorLabels.Remove(data);
}
}
}
public abstract class PopupLabel
{
public PopupType Type = PopupType.Small;
public string Text { get; set; } = string.Empty;
public float TotalTime { get; set; }
public int Repeats = 1;
}
public sealed class WorldPopupLabel(EntityCoordinates coordinates) : PopupLabel
{
/// <summary>
/// The original EntityCoordinates of the label.
/// </summary>
public EntityCoordinates InitialPos = coordinates;
}
public sealed class CursorPopupLabel(ScreenCoordinates screenCoords) : PopupLabel
{
public ScreenCoordinates InitialPos = screenCoords;
}
[UsedImplicitly]
private record struct WorldPopupData(
string Message,
PopupType Type,
EntityCoordinates Coordinates,
EntityUid? Entity);
[UsedImplicitly]
private record struct CursorPopupData(
string Message,
PopupType Type);
}

View file

@ -1,26 +1,5 @@
using Content.Client.Power.EntitySystems;
using Content.Shared.Popups;
using Content.Shared.Power.Components;
using Content.Shared.Power.EntitySystems;
using Content.Shared.UserInterface;
using Content.Shared.Wires;
namespace Content.Client.Power;
public sealed partial class ActivatableUIRequiresPowerSystem : SharedActivatableUIRequiresPowerSystem
{
[Dependency] private SharedPopupSystem _popup = default!;
protected override void OnActivate(Entity<ActivatableUIRequiresPowerComponent> ent, ref ActivatableUIOpenAttemptEvent args)
{
if (args.Cancelled || this.IsPowered(ent.Owner, EntityManager))
{
return;
}
if (!args.Silent)
_popup.PopupClient(Loc.GetString("base-computer-ui-component-not-powered", ("machine", ent.Owner)), args.User, args.User);
args.Cancel();
}
}
public sealed partial class ActivatableUIRequiresPowerSystem : SharedActivatableUIRequiresPowerSystem;

View file

@ -14,6 +14,10 @@ namespace Content.Client.RCD;
[UsedImplicitly]
public sealed partial class RCDMenuBoundUserInterface : BoundUserInterface
{
[Dependency] private IPrototypeManager _prototypeManager = default!;
[Dependency] private ISharedPlayerManager _playerManager = default!;
[Dependency] private PopupSystem _popup = default!;
private const string TopLevelActionCategory = "Main";
private static readonly Dictionary<string, (string Tooltip, SpriteSpecifier Sprite)> PrototypesGroupingInfo
@ -26,14 +30,10 @@ public sealed partial class RCDMenuBoundUserInterface : BoundUserInterface
["Lighting"] = ("rcd-component-lighting", new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/Radial/RCD/lighting.png"))),
};
[Dependency] private IPrototypeManager _prototypeManager = default!;
[Dependency] private ISharedPlayerManager _playerManager = default!;
private SimpleRadialMenu? _menu;
public RCDMenuBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{
IoCManager.InjectDependencies(this);
}
protected override void Open()
@ -134,8 +134,7 @@ public sealed partial class RCDMenuBoundUserInterface : BoundUserInterface
}
// Popup message
var popup = EntMan.System<PopupSystem>();
popup.PopupClient(msg, Owner, _playerManager.LocalSession.AttachedEntity);
_popup.PopupEntity(msg, Owner);
}
private string GetTooltip(RCDPrototype proto)

View file

@ -40,7 +40,7 @@ public sealed partial class RoundEndSummaryUIController : UIController,
return;
_window = new RoundEndSummaryWindow(message.GamemodeTitle, message.RoundEndText,
message.RoundDuration, message.RoundId, message.AllPlayersEndInfo, EntityManager);
message.RoundDuration, message.RoundId, message.AllPlayersEndInfo);
}
public void OnSystemLoaded(ClientGameTicker system)

View file

@ -1,171 +1,496 @@
using System.Linq;
using System.Numerics;
using Content.Client.Message;
using Content.Shared.GameTicking;
using RoundEndPlayerInfo = Content.Shared.GameTicking.RoundEndMessageEvent.RoundEndPlayerInfo;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Shared.Utility;
using static Robust.Client.UserInterface.Controls.BoxContainer;
namespace Content.Client.RoundEnd
namespace Content.Client.RoundEnd;
/// <summary>
/// Window displaying round end information including player manifest.
/// </summary>
public sealed partial class RoundEndSummaryWindow : DefaultWindow
{
public sealed class RoundEndSummaryWindow : DefaultWindow
[Dependency] private IEntityManager _entityManager = default!;
public int RoundId;
private readonly RoundEndPlayerInfo[] _playersInfo;
private GridContainer _playerGrid = null!;
private readonly List<SortButton> _sortButtons = [];
private string _searchText = string.Empty;
private enum SortField
{
private readonly IEntityManager _entityManager;
public int RoundId;
ICName,
Role,
PlayerType,
OOCName
}
public RoundEndSummaryWindow(string gm, string roundEnd, TimeSpan roundTimeSpan, int roundId,
RoundEndMessageEvent.RoundEndPlayerInfo[] info, IEntityManager entityManager)
private SortField _currentSortField = SortField.PlayerType;
private bool _sortDescending;
public RoundEndSummaryWindow(string gm, string roundEnd, TimeSpan roundTimeSpan, int roundId, RoundEndPlayerInfo[] info)
{
IoCManager.InjectDependencies(this);
_playersInfo = info;
MinSize = SetSize = new Vector2(720, 580);
Title = Loc.GetString("round-end-summary-window-title");
// The round end window is split into two tabs, one about the round stats
// and the other is a list of RoundEndPlayerInfo for each player.
// This tab would be a good place for things like: "x many people died.",
// "clown slipped the crew x times.", "x shots were fired this round.", etc.
// Also, good for serious info.
RoundId = roundId;
var roundEndTabs = new TabContainer();
roundEndTabs.AddChild(MakeRoundEndSummaryTab(gm, roundEnd, roundTimeSpan, roundId));
roundEndTabs.AddChild(MakePlayerManifestTab());
ContentsContainer.AddChild(roundEndTabs);
OpenCenteredRight();
MoveToFront();
}
private static BoxContainer MakeRoundEndSummaryTab(string gamemode, string roundEnd, TimeSpan roundDuration, int roundId)
{
var roundEndSummaryTab = new BoxContainer
{
_entityManager = entityManager;
Orientation = LayoutOrientation.Vertical,
Name = Loc.GetString("round-end-summary-window-round-end-summary-tab-title")
};
MinSize = SetSize = new Vector2(520, 580);
var roundEndSummaryContainerScrollbox = new ScrollContainer
{
VerticalExpand = true,
Margin = new Thickness(10)
};
var roundEndSummaryContainer = new BoxContainer
{
Orientation = LayoutOrientation.Vertical
};
Title = Loc.GetString("round-end-summary-window-title");
//Gamemode Name
var gamemodeLabel = new RichTextLabel();
var gamemodeMessage = new FormattedMessage();
gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-round-id-label", ("roundId", roundId)));
gamemodeMessage.AddText(" ");
gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-gamemode-name-label", ("gamemode", gamemode)));
gamemodeLabel.SetMessage(gamemodeMessage);
roundEndSummaryContainer.AddChild(gamemodeLabel);
// The round end window is split into two tabs, one about the round stats
// and the other is a list of RoundEndPlayerInfo for each player.
// This tab would be a good place for things like: "x many people died.",
// "clown slipped the crew x times.", "x shots were fired this round.", etc.
// Also good for serious info.
//Duration
var roundTimeLabel = new RichTextLabel();
roundTimeLabel.SetMarkup(Loc.GetString("round-end-summary-window-duration-label",
("hours", roundDuration.Hours),
("minutes", roundDuration.Minutes),
("seconds", roundDuration.Seconds)));
roundEndSummaryContainer.AddChild(roundTimeLabel);
RoundId = roundId;
var roundEndTabs = new TabContainer();
roundEndTabs.AddChild(MakeRoundEndSummaryTab(gm, roundEnd, roundTimeSpan, roundId));
roundEndTabs.AddChild(MakePlayerManifestTab(info));
ContentsContainer.AddChild(roundEndTabs);
OpenCenteredRight();
MoveToFront();
//Round end text
if (!string.IsNullOrEmpty(roundEnd))
{
var roundEndLabel = new RichTextLabel();
roundEndLabel.SetMarkup(roundEnd);
roundEndSummaryContainer.AddChild(roundEndLabel);
}
private BoxContainer MakeRoundEndSummaryTab(string gamemode, string roundEnd, TimeSpan roundDuration, int roundId)
roundEndSummaryContainerScrollbox.AddChild(roundEndSummaryContainer);
roundEndSummaryTab.AddChild(roundEndSummaryContainerScrollbox);
return roundEndSummaryTab;
}
private BoxContainer MakePlayerManifestTab()
{
var playerManifestTab = new BoxContainer
{
var roundEndSummaryTab = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
Name = Loc.GetString("round-end-summary-window-round-end-summary-tab-title")
};
Orientation = LayoutOrientation.Vertical,
Name = Loc.GetString("round-end-summary-window-player-manifest-tab-title")
};
var roundEndSummaryContainerScrollbox = new ScrollContainer
{
VerticalExpand = true,
Margin = new Thickness(10)
};
var roundEndSummaryContainer = new BoxContainer
{
Orientation = LayoutOrientation.Vertical
};
// Search container
var searchContainer = new BoxContainer
{
Orientation = LayoutOrientation.Horizontal,
Margin = new Thickness(10, 10, 10, 5)
};
//Gamemode Name
var gamemodeLabel = new RichTextLabel();
var gamemodeMessage = new FormattedMessage();
gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-round-id-label", ("roundId", roundId)));
gamemodeMessage.AddText(" ");
gamemodeMessage.AddMarkupOrThrow(Loc.GetString("round-end-summary-window-gamemode-name-label", ("gamemode", gamemode)));
gamemodeLabel.SetMessage(gamemodeMessage);
roundEndSummaryContainer.AddChild(gamemodeLabel);
var searchLabel = new Label
{
Text = "Filter: ",
VerticalAlignment = VAlignment.Center,
MinSize = new Vector2(40, 1)
};
//Duration
var roundTimeLabel = new RichTextLabel();
roundTimeLabel.SetMarkup(Loc.GetString("round-end-summary-window-duration-label",
("hours", roundDuration.Hours),
("minutes", roundDuration.Minutes),
("seconds", roundDuration.Seconds)));
roundEndSummaryContainer.AddChild(roundTimeLabel);
var searchBar = new LineEdit
{
PlaceHolder = Loc.GetString("round-end-summary-window-player-manifest-tab-search-placeholder"),
HorizontalExpand = true,
MinSize = new Vector2(200, 1)
};
//Round end text
if (!string.IsNullOrEmpty(roundEnd))
{
var roundEndLabel = new RichTextLabel();
roundEndLabel.SetMarkup(roundEnd);
roundEndSummaryContainer.AddChild(roundEndLabel);
}
searchBar.OnTextChanged += OnSearchTextChanged;
roundEndSummaryContainerScrollbox.AddChild(roundEndSummaryContainer);
roundEndSummaryTab.AddChild(roundEndSummaryContainerScrollbox);
searchContainer.AddChild(searchLabel);
searchContainer.AddChild(searchBar);
playerManifestTab.AddChild(searchContainer);
return roundEndSummaryTab;
// Header with sort buttons
var headerContainer = new BoxContainer
{
Orientation = LayoutOrientation.Horizontal,
Margin = new Thickness(10, 5, 10, 5)
};
var icNameButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-character", SortField.ICName);
var roleButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-role", SortField.Role);
var playerTypeButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-player-type", SortField.PlayerType);
var oocNameButton = CreateSortButton("round-end-summary-window-player-manifest-tab-sort-player", SortField.OOCName);
playerTypeButton.SetSortIndicator(true);
headerContainer.AddChild(icNameButton);
// Add small spacer between buttons
headerContainer.AddChild(new Control
{
MinSize = new Vector2(5, 1),
HorizontalExpand = false
});
headerContainer.AddChild(roleButton);
// Add small spacer between buttons
headerContainer.AddChild(new Control
{
MinSize = new Vector2(5, 1),
HorizontalExpand = false
});
headerContainer.AddChild(playerTypeButton);
// Add small spacer between buttons
headerContainer.AddChild(new Control
{
MinSize = new Vector2(5, 1),
HorizontalExpand = false
});
headerContainer.AddChild(oocNameButton);
playerManifestTab.AddChild(headerContainer);
var scrollContainer = new ScrollContainer
{
VerticalExpand = true,
Margin = new Thickness(10, 0, 10, 10),
};
_playerGrid = new GridContainer
{
Columns = 6, // Player Sprite,IC Name,Role,Player Type,OOC Name
HorizontalExpand = true,
};
RefreshPlayerList();
scrollContainer.AddChild(_playerGrid);
playerManifestTab.AddChild(scrollContainer);
return playerManifestTab;
}
private SortButton CreateSortButton(string text, SortField field)
{
var button = new SortButton(Loc.GetString(text), field);
button.OnPressed += _ => SortBy(field);
_sortButtons.Add(button);
return button;
}
/// <summary>
/// Handles sorting by the specified field, toggling direction if the same field is clicked
/// </summary>
private void SortBy(SortField field)
{
if (_currentSortField == field)
{
_sortDescending = !_sortDescending;
}
else
{
_currentSortField = field;
_sortDescending = false;
}
private BoxContainer MakePlayerManifestTab(RoundEndMessageEvent.RoundEndPlayerInfo[] playersInfo)
foreach (var button in _sortButtons)
{
var playerManifestTab = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
Name = Loc.GetString("round-end-summary-window-player-manifest-tab-title")
};
button.SetSortIndicator(button.Field == _currentSortField, _sortDescending);
}
var playerInfoContainerScrollbox = new ScrollContainer
{
VerticalExpand = true,
Margin = new Thickness(10)
};
var playerInfoContainer = new BoxContainer
{
Orientation = LayoutOrientation.Vertical
};
RefreshPlayerList();
}
//Put observers at the bottom of the list. Put antags on top.
var sortedPlayersInfo = playersInfo.OrderBy(p => p.Observer).ThenBy(p => !p.Antag);
/// <summary>
/// Refreshes the player list grid by clearing it and repopulating with sorted player data
/// </summary>
private void RefreshPlayerList()
{
_playerGrid.RemoveAllChildren();
//Create labels for each player info.
foreach (var playerInfo in sortedPlayersInfo)
{
var hBox = new BoxContainer
{
Orientation = LayoutOrientation.Horizontal,
};
var playerInfoText = new RichTextLabel
{
VerticalAlignment = VAlignment.Center,
VerticalExpand = true,
};
if (playerInfo.PlayerNetEntity != null)
{
hBox.AddChild(new SpriteView(playerInfo.PlayerNetEntity.Value, _entityManager)
{
OverrideDirection = Direction.South,
VerticalAlignment = VAlignment.Center,
SetSize = new Vector2(32, 32),
VerticalExpand = true,
});
}
if (playerInfo.PlayerICName != null)
{
if (playerInfo.Observer)
{
playerInfoText.SetMarkup(
Loc.GetString("round-end-summary-window-player-info-if-observer-text",
("playerOOCName", playerInfo.PlayerOOCName),
("playerICName", playerInfo.PlayerICName)));
}
else
{
//TODO: On Hover display a popup detailing more play info.
//For example: their antag goals and if they completed them sucessfully.
var icNameColor = playerInfo.Antag ? "red" : "white";
playerInfoText.SetMarkup(
Loc.GetString("round-end-summary-window-player-info-if-not-observer-text",
("playerOOCName", playerInfo.PlayerOOCName),
("icNameColor", icNameColor),
("playerICName", playerInfo.PlayerICName),
("playerRole", Loc.GetString(playerInfo.Role))));
}
}
hBox.AddChild(playerInfoText);
playerInfoContainer.AddChild(hBox);
}
playerInfoContainerScrollbox.AddChild(playerInfoContainer);
playerManifestTab.AddChild(playerInfoContainerScrollbox);
return playerManifestTab;
var sortedPlayers = GetSortedPlayers();
foreach (var playerInfo in sortedPlayers)
{
AddPlayerRow(playerInfo);
}
}
/// <summary>
/// Adds a single player row to the grid with all columns (sprite, IC name, role, player type, OOC name)
/// </summary>
private void AddPlayerRow(RoundEndPlayerInfo playerInfo)
{
// Player Sprite column
if (playerInfo.PlayerNetEntity != null)
{
_playerGrid.AddChild(new SpriteView(playerInfo.PlayerNetEntity.Value, _entityManager)
{
OverrideDirection = Direction.South,
VerticalAlignment = VAlignment.Center,
SetSize = new Vector2(32, 32),
});
}
else
{
_playerGrid.AddChild(new Control
{
SetSize = new Vector2(32, 32),
});
}
// IC Name column
var icNameLabel = new Label
{
Text = playerInfo.PlayerICName ?? playerInfo.PlayerOOCName,
VerticalAlignment = VAlignment.Center,
HorizontalExpand = true,
ClipText = true
};
// Apply color coding for antagonists
if (playerInfo.Antag)
{
icNameLabel.FontColorOverride = Color.Red;
}
_playerGrid.AddChild(icNameLabel);
_playerGrid.AddChild(new Control
{
SetSize = new Vector2(32, 32),
});
// Role column
var roleLabel = new Label
{
Text = playerInfo.Observer ? "-" : Loc.GetString(playerInfo.Role),
VerticalAlignment = VAlignment.Center,
HorizontalExpand = true,
ClipText = true
};
_playerGrid.AddChild(roleLabel);
// Player Type column
var playerTypeLabel = new Label
{
Text = GetPlayerTypeText(playerInfo),
VerticalAlignment = VAlignment.Center,
HorizontalExpand = true,
ClipText = true
};
// Apply color coding based on player type
if (playerInfo.Antag)
{
playerTypeLabel.FontColorOverride = Color.Red;
}
else if (playerInfo.Observer)
{
playerTypeLabel.FontColorOverride = Color.Gray;
}
_playerGrid.AddChild(playerTypeLabel);
// OOC Name column
var oocNameLabel = new Label
{
Text = playerInfo.PlayerOOCName,
VerticalAlignment = VAlignment.Center,
HorizontalExpand = true,
ClipText = true
};
_playerGrid.AddChild(oocNameLabel);
}
/// <summary>
/// Gets the player type text for a player based on their observer and antagonist flags
/// </summary>
private static string GetPlayerTypeText(RoundEndPlayerInfo playerInfo)
{
if (playerInfo.Observer)
return Loc.GetString("round-end-summary-window-player-manifest-tab-sort-player-type-observer");
if (playerInfo.Antag)
return Loc.GetString("round-end-summary-window-player-manifest-tab-sort-player-type-antag");
return Loc.GetString("round-end-summary-window-player-manifest-tab-sort-player-type-crew");
}
private IEnumerable<RoundEndPlayerInfo> GetSortedPlayers()
{
// First filter players based on search text
var filteredPlayers = string.IsNullOrEmpty(_searchText)
? _playersInfo
: _playersInfo.Where(PlayerMatchesSearch);
static string GetIcKey(RoundEndPlayerInfo p) =>
(p.PlayerICName ?? p.PlayerOOCName).ToLowerInvariant();
static string GetOocKey(RoundEndPlayerInfo p) =>
p.PlayerOOCName.ToLowerInvariant();
static string GetRoleKey(RoundEndPlayerInfo p) =>
(p.Observer ? "zzz_observer" : p.Role).ToLowerInvariant();
static int GetPlayerTypeSortKey(RoundEndPlayerInfo p) =>
p.Antag ? 1 : p.Observer ? 3 : 2;
return _currentSortField switch
{
SortField.ICName => ApplySort(filteredPlayers, GetIcKey, _sortDescending),
SortField.OOCName => ApplySort(filteredPlayers, GetOocKey, _sortDescending),
SortField.Role => ApplySort(filteredPlayers, GetRoleKey, _sortDescending),
SortField.PlayerType => ApplySort(filteredPlayers, GetPlayerTypeSortKey, _sortDescending),
_ => filteredPlayers
};
}
private static IEnumerable<RoundEndPlayerInfo> ApplySort<TKey>(
IEnumerable<RoundEndPlayerInfo> players,
Func<RoundEndPlayerInfo, TKey> primaryKey,
bool descending)
{
static string SecondaryKey(RoundEndPlayerInfo p) =>
(p.PlayerICName ?? p.PlayerOOCName).ToLowerInvariant();
return descending
? players.OrderByDescending(primaryKey).ThenByDescending(SecondaryKey)
: players.OrderBy(primaryKey).ThenBy(SecondaryKey);
}
/// <summary>
/// Gets a sort key for player type to ensure consistent ordering: Antagonist -> Crew -> Observer
/// </summary>
/// <summary>
/// Checks if a player matches the current search filter
/// </summary>
private bool PlayerMatchesSearch(RoundEndPlayerInfo playerInfo)
{
if (string.IsNullOrEmpty(_searchText))
return true;
// Search in character name (IC name)
if (!string.IsNullOrEmpty(playerInfo.PlayerICName) &&
playerInfo.PlayerICName.Contains(_searchText, StringComparison.OrdinalIgnoreCase))
return true;
// Search in player name (OOC name)
if (!string.IsNullOrEmpty(playerInfo.PlayerOOCName) &&
playerInfo.PlayerOOCName.Contains(_searchText, StringComparison.OrdinalIgnoreCase))
return true;
// Search in role
if (!string.IsNullOrEmpty(playerInfo.Role))
{
if (playerInfo.Role.Contains(_searchText, StringComparison.OrdinalIgnoreCase) ||
Loc.GetString(playerInfo.Role).Contains(_searchText, StringComparison.OrdinalIgnoreCase))
return true;
}
// Search in player type
var playerType = GetPlayerTypeText(playerInfo);
if (playerType.Contains(_searchText, StringComparison.OrdinalIgnoreCase))
return true;
// Search for "Observer" when they are observers
if (playerInfo.Observer && "observer".Contains(_searchText, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
/// <summary>
/// Handles search text changes and refreshes the player list
/// </summary>
private void OnSearchTextChanged(LineEdit.LineEditEventArgs args)
{
_searchText = args.Text;
RefreshPlayerList();
}
private sealed class SortButton : Button
{
public SortField Field { get; }
private readonly Label _sortIndicator;
public SortButton(string text, SortField field)
{
Field = field;
HorizontalExpand = true;
var container = new BoxContainer
{
Orientation = LayoutOrientation.Horizontal,
HorizontalExpand = true
};
var label = new Label
{
Text = text,
HorizontalExpand = true
};
_sortIndicator = new Label
{
Text = "",
HorizontalAlignment = HAlignment.Right,
MinSize = new Vector2(15, 1)
};
container.AddChild(label);
container.AddChild(_sortIndicator);
AddChild(container);
}
public void SetSortIndicator(bool active, bool descending = false)
{
if (!active)
{
_sortIndicator.Text = "";
return;
}
_sortIndicator.Text = descending ? "▼" : "▲";
}
}
}

View file

@ -103,6 +103,8 @@ namespace Content.Client.Sandbox
if (_placement.Eraser)
_placement.ToggleEraser();
_placement.Direction = _transform.GetWorldRotation(uid).GetCardinalDir();
_placement.BeginPlacing(new()
{
EntityType = comp.EntityPrototype.ID,
@ -121,6 +123,9 @@ namespace Content.Client.Sandbox
if (_placement.Eraser)
_placement.ToggleEraser();
_placement.Direction = (Direction)(tileRef.Tile.RotationMirroring % 4 * 2);
_placement.Mirrored = tileRef.Tile.RotationMirroring >= 4;
_placement.BeginPlacing(new()
{
EntityType = null,

View file

@ -1,69 +1,65 @@
<controls:BoxContainer Visible="False"
HorizontalExpand="True"
xmlns:controls="https://spacestation14.io"
xmlns:ui="clr-namespace:Content.Client.Shuttles.UI"
xmlns:controls1="clr-namespace:Content.Client.UserInterface.Controls">
<ui:ShuttleNavControl Name="NavRadar"
MouseFilter="Stop"
VerticalAlignment="Stretch"
VerticalExpand="True"
HorizontalExpand="True"
Margin="5 4 10 5"/>
<!-- Nav controls -->
<controls:BoxContainer Name="RightDisplayNav"
VerticalAlignment="Top"
HorizontalAlignment="Right"
VerticalExpand="True"
MinWidth="256"
MaxWidth="256"
Margin="5 0 5 5"
Orientation="Vertical">
<controls1:StripeBack
MinSize="48 48">
<controls:Label Name="NavDisplayLabel" Text="{controls:Loc 'shuttle-console-display-label'}"
VerticalExpand="True"
HorizontalAlignment="Center"/>
</controls1:StripeBack>
<controls:GridContainer Columns="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
HorizontalExpand="True"
Margin="3"
Name="ReadonlyDisplay">
<controls:Label Text="{controls:Loc 'shuttle-console-position'}"/>
<controls:Label Name="GridPosition"
Text="0.0, 0.0"
HorizontalExpand="True"
Align="Right"/>
<controls:Label Text="{controls:Loc 'shuttle-console-orientation'}"/>
<controls:Label Name="GridOrientation"
Text="0.0"
HorizontalExpand="True"
Align="Right"/>
<controls:Label Text="{controls:Loc 'shuttle-console-linear-velocity'}"/>
<controls:Label Name="GridLinearVelocity"
Text="0.0, 0.0"
HorizontalExpand="True"
Align="Right"/>
<controls:Label Text="{controls:Loc 'shuttle-console-angular-velocity'}"/>
<controls:Label Name="GridAngularVelocity"
Text="0.0"
HorizontalExpand="True"
Align="Right"/>
</controls:GridContainer>
<controls1:StripeBack
MinSize="48 48">
<controls:Label Name="NavSettingsLabel" Text="{controls:Loc 'shuttle-console-nav-settings'}"
VerticalExpand="True"
HorizontalAlignment="Center"/>
</controls1:StripeBack>
<controls:Button Name="IFFToggle"
Text="{controls:Loc 'shuttle-console-iff-toggle'}"
TextAlign="Center"
ToggleMode="True"/>
<controls:Button Name="DockToggle"
Text="{controls:Loc 'shuttle-console-dock-toggle'}"
TextAlign="Center"
ToggleMode="True"/>
</controls:BoxContainer>
</controls:BoxContainer>
<BoxContainer
Visible="False"
HorizontalExpand="True"
xmlns="https://spacestation14.io"
xmlns:ui="clr-namespace:Content.Client.Shuttles.UI"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls">
<ui:ShuttleNavControl
Name="NavRadar"
MouseFilter="Stop"
VerticalAlignment="Stretch"
VerticalExpand="True"
HorizontalExpand="True"
Margin="5 4 10 5"/>
<!-- Nav controls -->
<BoxContainer
Name="RightDisplayNav"
VerticalAlignment="Top"
HorizontalAlignment="Right"
VerticalExpand="True"
MinWidth="256"
MaxWidth="256"
Margin="5 0 5 5"
Orientation="Vertical">
<controls:StripeBack MinSize="48 48">
<Label
Name="NavDisplayLabel"
Text="{Loc 'shuttle-console-display-label'}"
VerticalExpand="True"
HorizontalAlignment="Center"/>
</controls:StripeBack>
<GridContainer
Columns="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
HorizontalExpand="True"
Margin="3"
Name="ReadonlyDisplay">
<Label Text="{Loc 'shuttle-console-position'}" />
<Label Name="GridPosition" Text="0.0, 0.0" HorizontalExpand="True" Align="Right" />
<Label Text="{Loc 'shuttle-console-orientation'}" />
<Label Name="GridOrientation" Text="0.0" HorizontalExpand="True" Align="Right" />
<Label Text="{Loc 'shuttle-console-linear-velocity'}" />
<Label Name="GridLinearVelocity" Text="0.0, 0.0" HorizontalExpand="True" Align="Right" />
<Label Text="{Loc 'shuttle-console-angular-velocity'}" />
<Label Name="GridAngularVelocity" Text="0.0" HorizontalExpand="True" Align="Right" />
</GridContainer>
<controls:StripeBack MinSize="48 48">
<Label
Name="NavSettingsLabel"
Text="{Loc 'shuttle-console-nav-settings'}"
VerticalExpand="True"
HorizontalAlignment="Center"/>
</controls:StripeBack>
<Button
Name="IFFToggle"
Text="{Loc 'shuttle-console-iff-toggle'}"
TextAlign="Center"
ToggleMode="True"/>
<Button
Name="DockToggle"
Text="{Loc 'shuttle-console-dock-toggle'}"
TextAlign="Center"
ToggleMode="True"/>
</BoxContainer>
</BoxContainer>

View file

@ -298,11 +298,16 @@ public sealed partial class ShuttleNavControl : BaseShuttleControl
if (!ShowDocks)
return;
const float DockScale = 0.6f;
const float dockScale = 0.6f;
var topLeft = new Vector2(-dockScale, -dockScale);
var topRight = new Vector2(dockScale, -dockScale);
var bottomRight = new Vector2(dockScale, dockScale);
var bottomLeft = new Vector2(-dockScale, dockScale);
var nent = EntManager.GetNetEntity(uid);
const float sqrt2 = 1.41421356f;
const float dockRadius = DockScale * sqrt2;
const float dockRadius = dockScale * sqrt2;
// Worst-case bounds used to cull a dock:
Box2 viewBounds = new Box2(
-dockRadius * UIScale,
@ -312,6 +317,7 @@ public sealed partial class ShuttleNavControl : BaseShuttleControl
if (_docks.TryGetValue(nent, out var docks))
{
Span<Vector2> verts = new Vector2[4];
foreach (var state in docks)
{
var position = state.Coordinates.Position;
@ -324,13 +330,10 @@ public sealed partial class ShuttleNavControl : BaseShuttleControl
var color = Color.ToSrgb(state.HighlightedColor);
var verts = new[]
{
Vector2.Transform(position + new Vector2(-DockScale, -DockScale), gridToView),
Vector2.Transform(position + new Vector2(DockScale, -DockScale), gridToView),
Vector2.Transform(position + new Vector2(DockScale, DockScale), gridToView),
Vector2.Transform(position + new Vector2(-DockScale, DockScale), gridToView),
};
verts[0] = Vector2.Transform(position + topLeft, gridToView);
verts[1] = Vector2.Transform(position + topRight, gridToView);
verts[2] = Vector2.Transform(position + bottomRight, gridToView);
verts[3] = Vector2.Transform(position + bottomLeft, gridToView);
handle.DrawPrimitives(DrawPrimitiveTopology.TriangleFan, verts, color.WithAlpha(0.8f));
handle.DrawPrimitives(DrawPrimitiveTopology.LineStrip, verts, color);

View file

@ -41,7 +41,7 @@ namespace Content.Client.Singularity
_entMan.EventBus.SubscribeEvent<PixelToMapEvent>(EventSource.Local, this, OnProjectFromScreenToMap);
ZIndex = 101; // Should be drawn after the placement overlay so admins placing items near the singularity can tell where they're going.
_configManager.OnValueChanged(CCVars.ReducedMotion, (b) => { _reducedMotion = b; }, invokeImmediately: true);
_configManager.OnValueChanged(CCVars.DisableSinguloWarp, (b) => { _reducedMotion = b; }, invokeImmediately: true);
}
private readonly Vector2[] _positions = new Vector2[MaxCount];

View file

@ -95,8 +95,8 @@ public sealed partial class StatusIconOverlay : Overlay
accOffsetL += texture.Height;
countL++;
}
yOffset = (bounds.Height + sprite.Offset.Y) / 2f - (float)(accOffsetL - proto.Offset) / EyeManager.PixelsPerMeter;
xOffset = -(bounds.Width + sprite.Offset.X) / 2f + (float)proto.OffsetHorizontal / EyeManager.PixelsPerMeter;
yOffset = sprite.Offset.Y + bounds.Height / 2f - (float)(accOffsetL - proto.Offset) / EyeManager.PixelsPerMeter;
xOffset = sprite.Offset.X - bounds.Width / 2f + (float)proto.OffsetHorizontal / EyeManager.PixelsPerMeter;
}
else
@ -108,8 +108,8 @@ public sealed partial class StatusIconOverlay : Overlay
accOffsetR += texture.Height;
countR++;
}
yOffset = (bounds.Height + sprite.Offset.Y) / 2f - (float)(accOffsetR - proto.Offset) / EyeManager.PixelsPerMeter;
xOffset = (bounds.Width + sprite.Offset.X) / 2f - (float)(texture.Width - proto.OffsetHorizontal) / EyeManager.PixelsPerMeter;
yOffset = sprite.Offset.Y + bounds.Height / 2f - (float)(accOffsetR - proto.Offset) / EyeManager.PixelsPerMeter;
xOffset = sprite.Offset.X + bounds.Width / 2f - (float)(texture.Width - proto.OffsetHorizontal) / EyeManager.PixelsPerMeter;
}

View file

@ -1,7 +0,0 @@
using Content.Shared.Stunnable;
namespace Content.Client.Stunnable;
public sealed class StunbatonSystem : SharedStunbatonSystem
{
}

View file

@ -23,7 +23,8 @@ public sealed partial class TippyUI : UIWidget
if (visuals == null)
return;
Label.ModulateSelfOverride = visuals.FontAccentColor;
if (visuals.DefaultTextColor != null)
Label.ModulateSelfOverride = visuals.DefaultTextColor;
if (visuals.BackgroundImagePath == null)
return;

View file

@ -143,7 +143,7 @@ public sealed partial class TippyUIController : UIController
paper.BackgroundImagePath = "/Textures/Interface/Paper/paper_background_default.svg.96dpi.png";
paper.BackgroundPatchMargin = new(16f, 16f, 16f, 16f);
paper.BackgroundModulate = new(255, 255, 204);
paper.FontAccentColor = new(0, 0, 0);
paper.DefaultTextColor = new(0, 0, 0);
}
tippy.InitLabel(EntityManager.GetComponentOrNull<PaperVisualsComponent>(_entity), _resCache);

View file

@ -20,8 +20,15 @@ public sealed partial class ConfirmButton : Button
public const string ConfirmPrefix = "confirm-";
/// <summary>
/// The time when the button will revert from confirming if left unpressed.
/// </summary>
private TimeSpan? _nextReset;
/// <summary>
/// The time when the button should re-enable itself (to avoid debouncing).
/// </summary>
private TimeSpan? _nextCooldown;
private bool _isConfirming;
private string? _confirmationText;
private string? _text;
@ -30,6 +37,11 @@ public sealed partial class ConfirmButton : Button
/// </summary>
public new event Action<ButtonEventArgs>? OnPressed;
/// <summary>
/// Fired when the button has started to confirm and is awaiting a second button press.
/// </summary>
public event Action<ButtonEventArgs>? OnConfirming;
/// <inheritdoc cref="Button.Text"/>
/// <remarks>
/// Hides the buttons text property to be able to sanely replace the button text with
@ -41,7 +53,23 @@ public sealed partial class ConfirmButton : Button
set
{
_text = value;
base.Text = IsConfirming ? _confirmationText : value;
UpdateText();
}
}
/// <inheritdoc cref="BaseButton.Disabled"/>
/// <remarks>
/// Overrides the confirming state of the button when set.
/// Intended for public use, should not be used inside the class due to IsConfirming side-effects.
/// </remarks>
public new bool Disabled
{
get => base.Disabled;
set
{
// Force out of confirming status (may change base.Disabled), then write your new disabled value.
SetIsConfirming(false);
SetDisabled(value);
}
}
@ -52,7 +80,11 @@ public sealed partial class ConfirmButton : Button
public string ConfirmationText
{
get => _confirmationText ?? Loc.GetString("generic-confirm");
set => _confirmationText = value;
set
{
_confirmationText = value;
UpdateText();
}
}
/// <summary>
@ -67,8 +99,19 @@ public sealed partial class ConfirmButton : Button
[ViewVariables(VVAccess.ReadWrite)]
public TimeSpan CooldownTime { get; set; } = TimeSpan.FromSeconds(.5);
/// <summary>
/// A property to get or change whether the button is confirming (awaiting a second press within a time limit) or not
/// </summary>
[ViewVariables]
public bool IsConfirming = false;
public bool IsConfirming
{
get => _isConfirming;
set
{
if (_isConfirming != value)
SetIsConfirming(value);
}
}
public ConfirmButton()
{
@ -79,15 +122,13 @@ public sealed partial class ConfirmButton : Button
protected override void FrameUpdate(FrameEventArgs args)
{
if (IsConfirming && _gameTiming.CurTime > _nextReset)
{
IsConfirming = false;
base.Text = Text;
DrawModeChanged();
}
if (!IsConfirming)
return;
if (Disabled && _gameTiming.CurTime > _nextCooldown)
Disabled = false;
if (_gameTiming.CurTime > _nextReset)
SetIsConfirming(false);
else if (Disabled && _gameTiming.CurTime > _nextCooldown)
SetDisabled(false);
}
protected override void DrawModeChanged()
@ -111,10 +152,11 @@ public sealed partial class ConfirmButton : Button
default:
throw new ArgumentOutOfRangeException();
}
return;
}
base.DrawModeChanged();
else
{
base.DrawModeChanged();
}
}
private void HandleOnPressed(ButtonEventArgs buttonEvent)
@ -123,20 +165,62 @@ public sealed partial class ConfirmButton : Button
if (IsConfirming && _nextCooldown > _gameTiming.CurTime)
return;
// Update the state machine before invoking our events.
SetIsConfirming(!IsConfirming);
switch (IsConfirming)
{
case false:
_nextCooldown = _gameTiming.CurTime + CooldownTime;
_nextReset = _gameTiming.CurTime + ResetTime;
Disabled = true;
break;
case true:
OnConfirming?.Invoke(buttonEvent);
break;
case false:
OnPressed?.Invoke(buttonEvent);
break;
}
}
base.Text = IsConfirming ? Text : ConfirmationText;
/// <summary>
/// Updates the text shown on the button depending on whether the button is confirming or not.
/// </summary>
private void UpdateText()
{
base.Text = IsConfirming ? ConfirmationText : Text;
}
IsConfirming = !IsConfirming;
/// <summary>
/// Internal setter logi
/// </summary>
private void SetDisabled(bool value)
{
base.Disabled = value;
}
/// <summary>
/// Internal setter logic for <see cref="IsConfirming"/>
/// Sets the text, the timers, and the disabled state.
/// </summary>
private void SetIsConfirming(bool value)
{
_isConfirming = value;
// Update button visuals.
UpdateText();
DrawModeChanged();
if (value)
{
// Start our timers, disable button until cooldown.
SetDisabled(true);
_nextCooldown = _gameTiming.CurTime + CooldownTime;
_nextReset = _gameTiming.CurTime + ResetTime;
}
else
{
// Clear timers, ensure button is enabled if it was disabled before (previous SetIsConfirming(true) call => valid reset timer)
if (Disabled && _nextReset != null)
SetDisabled(false);
_nextReset = null;
_nextCooldown = null;
}
}
}

View file

@ -1,6 +1,21 @@
<DefaultWindow xmlns="https://spacestation14.io" Title="{Loc 'ghost-target-window-title'}" MinSize="450 450" SetSize="450 450">
<DefaultWindow xmlns="https://spacestation14.io" Title="{Loc 'ghost-target-window-title'}" MinSize="450 450"
SetSize="450 500">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" SizeFlagsStretchRatio="0.4">
<Button Name="GhostnadoButton" Text="{Loc 'ghost-target-window-warp-to-most-followed'}" HorizontalAlignment="Center" Margin="0 4" />
<BoxContainer Orientation="Vertical" Margin="0 4 0 4">
<RichTextLabel Text="{Loc 'ghost-target-window-warp-to-title'}" HorizontalAlignment="Center" />
<PanelContainer StyleClasses="LowDivider" Margin="0 5" />
<BoxContainer Orientation="Horizontal" Align="Center">
<Button Name="GhostnadoButton" Text="{Loc 'ghost-target-window-warp-to-most-followed'}"
ToolTip="{Loc 'ghost-target-window-warp-to-most-followed-tooltip'}"
HorizontalAlignment="Center" StyleClasses="OpenRight" />
<Button Name="WarpToRandomFollowedButton" Text="{Loc 'ghost-target-window-warp-to-random-followed'}"
ToolTip="{Loc 'ghost-target-window-warp-to-random-followed-tooltip'}"
HorizontalAlignment="Center" StyleClasses="OpenBoth" />
<Button Name="WarpToRandomButton" Text="{Loc 'ghost-target-window-warp-to-random'}"
ToolTip="{Loc 'ghost-target-window-warp-to-random-tooltip'}" HorizontalAlignment="Center"
StyleClasses="OpenLeft" />
</BoxContainer>
</BoxContainer>
<LineEdit Name="SearchBar" PlaceHolder="Search" HorizontalExpand="True" Margin="0 4" />
<ScrollContainer Name="GhostScroll" VerticalExpand="True" HorizontalExpand="True" HScrollEnabled="False">
<BoxContainer Name="ButtonContainer" Orientation="Vertical" VerticalExpand="True" SeparationOverride="5">

View file

@ -16,6 +16,8 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls
public event Action<NetEntity>? WarpClicked;
public event Action? OnGhostnadoClicked;
public event Action? OnWarpToRandomFollowedClicked;
public event Action? OnWarpToRandomClicked;
public GhostTargetWindow()
{
@ -23,6 +25,8 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls
SearchBar.OnTextChanged += OnSearchTextChanged;
GhostnadoButton.OnPressed += _ => OnGhostnadoClicked?.Invoke();
WarpToRandomFollowedButton.OnPressed += _ => OnWarpToRandomFollowedClicked?.Invoke();
WarpToRandomButton.OnPressed += _ => OnWarpToRandomClicked?.Invoke();
}
public void UpdateWarps(IEnumerable<GhostWarp> warps)

View file

@ -117,6 +117,18 @@ public sealed partial class GhostUIController : UIController, IOnSystemChanged<G
_net.SendSystemNetworkMessage(msg);
}
private void OnWarpToRandomFollowedClicked()
{
var msg = new WarpToRandomFollowedRequestEvent();
_net.SendSystemNetworkMessage(msg);
}
private void OnWarpToRandomClicked()
{
var msg = new WarpToRandomRequestEvent();
_net.SendSystemNetworkMessage(msg);
}
public void LoadGui()
{
if (Gui == null)
@ -127,6 +139,8 @@ public sealed partial class GhostUIController : UIController, IOnSystemChanged<G
Gui.GhostRolesPressed += GhostRolesPressed;
Gui.TargetWindow.WarpClicked += OnWarpClicked;
Gui.TargetWindow.OnGhostnadoClicked += OnGhostnadoClicked;
Gui.TargetWindow.OnWarpToRandomFollowedClicked += OnWarpToRandomFollowedClicked;
Gui.TargetWindow.OnWarpToRandomClicked += OnWarpToRandomClicked;
UpdateGui();
}

View file

@ -30,7 +30,6 @@ public sealed partial class GuidebookUIController : UIController, IOnStateEntere
private GuidebookWindow? _guideWindow;
private MenuButton? GuidebookButton => UIManager.GetActiveUIWidgetOrNull<MenuBar.Widgets.GameTopMenuBar>()?.GuidebookButton;
private ProtoId<GuideEntryPrototype>? _lastEntry;
public void OnStateEntered(LobbyState state)
{
@ -145,7 +144,6 @@ public sealed partial class GuidebookUIController : UIController, IOnStateEntere
if (_guideWindow != null)
{
_guideWindow.ReturnContainer.Visible = false;
_lastEntry = _guideWindow.LastEntry;
}
}
@ -183,7 +181,7 @@ public sealed partial class GuidebookUIController : UIController, IOnStateEntere
if (guides == null)
{
guides = _prototypeManager.EnumeratePrototypes<GuideEntryPrototype>()
.ToDictionary(x => new ProtoId<GuideEntryPrototype>(x.ID), x => (GuideEntry) x);
.ToDictionary(x => new ProtoId<GuideEntryPrototype>(x.ID), x => (GuideEntry)x);
}
else if (includeChildren)
{
@ -197,20 +195,23 @@ public sealed partial class GuidebookUIController : UIController, IOnStateEntere
if (selected == null)
{
if (_lastEntry is { } lastEntry && guides.ContainsKey(lastEntry))
if (_guideWindow.Selected is { } lastEntry && guides.ContainsKey(lastEntry))
{
selected = _lastEntry;
selected = lastEntry;
}
else
{
selected = _configuration.GetCVar(CCVars.DefaultGuide);
}
}
_guideWindow.UpdateGuides(guides, rootEntries, forceRoot, selected);
var changed = _guideWindow.UpdateGuides(guides, rootEntries, forceRoot, selected);
// Expand up to depth-2.
_guideWindow.Tree.SetAllExpanded(false);
_guideWindow.Tree.SetAllExpanded(true, 1);
if (changed)
{
_guideWindow.Tree.SetAllExpanded(false);
_guideWindow.Tree.SetAllExpanded(true, 1);
}
_guideWindow.OpenCenteredRight();
}

View file

@ -19,7 +19,7 @@
</graphics:StyleBoxTexture>
</PanelContainer.PanelOverride>
</PanelContainer>
<BoxContainer Name="Contents" Orientation="Vertical" Margin="0 6 0 4" RectClipContent="True">
<BoxContainer Name="Contents" Orientation="Vertical" Margin="2 6 2 4" RectClipContent="True">
<BoxContainer Name="StatusContents" Orientation="Vertical" VerticalExpand="True" VerticalAlignment="Bottom" />
<Control>
<Label Name="NoItemLabel" ClipText="True" StyleClasses="ItemStatusNotHeld" Align="Left" Text="{Loc 'item-status-not-held'}" />

View file

@ -1,6 +1,7 @@
using Content.Client.Items;
using Content.Shared.Hands.Components;
using Content.Shared.IdentityManagement;
using Content.Shared.Inventory;
using Content.Shared.Inventory.VirtualItem;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
@ -159,6 +160,13 @@ public sealed partial class ItemStatusPanel : Control
return;
}
if (_entityManager.HasComponent<BlockItemNameInHandComponent>(_entity))
{
StatusContents.VerticalAlignment = VAlignment.Center;
ItemNameLabel.Visible = false;
return;
}
if (_entityManager.TryGetComponent(_entity, out VirtualItemComponent? virtualItem)
&& _entityManager.EntityExists(virtualItem.BlockingEntity))
{

View file

@ -261,3 +261,73 @@ public sealed class BatteryBulletRenderer : BaseBulletRenderer
handle.DrawRect(UIBox2.FromDimensions(renderPos, new Vector2(SizeH, SizeV)), color);
}
}
public sealed class CustomBulletRenderer : BaseBulletRenderer
{
private readonly Texture _loadedSprite;
private readonly Texture _spentSprite;
private const int Separation = 2;
public CustomBulletRenderer(Texture loadedSprite, Texture spentSprite, int numberOfRows)
{
_loadedSprite = loadedSprite;
_spentSprite = spentSprite;
Rows = numberOfRows;
Parameters = new LayoutParameters
{
ItemWidth = _loadedSprite.Width,
ItemHeight = _loadedSprite.Height,
ItemSeparation = _loadedSprite.Width + Separation,
MinCountPerRow = 3,
VerticalSeparation = Separation,
};
}
protected override void Draw(DrawingHandleScreen handle)
{
var itemSeparation = _loadedSprite.Width + Separation;
var numberItemsPerRow = (int)Math.Ceiling((double)Capacity / Rows);
// if there is more than one item per row, then we need to recalculate the separation between them.
if (numberItemsPerRow > 1)
{
// the item separation is the distance from the start of a item to the start of the next item,
// so the total horizontal size will be the item separation times the number of items - 1 + item width.
// the reason for the -1 and the + + item width. is that the last item doesn't have a item after it,
// so instead of adding the last item separation we add the item width.
// converting into a equation:
// total horizontal size = item separation * (number of items - 1) + width of the item
// converting to the variables:
// Size.X = itemSeparation * (numberItemsPerRow - 1) + _loadedSprite.Width
// Size.X - _loadedSprite.Width = itemSeparation * (numberItemsPerRow - 1)
// (Size.X - _loadedSprite.Width) / (numberItemsPerRow - 1) = itemSeparation
itemSeparation = (int)(Size.X - _loadedSprite.Width) / (numberItemsPerRow - 1);
// make sure the itemSeparation is less or equal to the standard item separation
itemSeparation = int.Min(_loadedSprite.Width + Separation, itemSeparation);
// make sure the itemSeparation is greater or equal to 1
itemSeparation = int.Max(1, itemSeparation);
}
Parameters = new LayoutParameters()
{
ItemWidth = _loadedSprite.Width,
ItemHeight = _loadedSprite.Height,
ItemSeparation = itemSeparation,
MinCountPerRow = 3,
VerticalSeparation = Separation,
};
base.Draw(handle);
}
protected override void DrawItem(DrawingHandleScreen handle, Vector2 renderPos, bool spent, bool altColor)
{
handle.DrawTexture(spent ? _spentSprite : _loadedSprite, renderPos);
}
}

View file

@ -158,6 +158,30 @@ public sealed partial class GunSystem
}
}
public sealed class CustomIconStatusControl : Control
{
private readonly CustomBulletRenderer _customBulletRenderer;
public CustomIconStatusControl(Texture loadedSprite, Texture spentTexture, int numberOfRows)
{
MinHeight = 15;
HorizontalExpand = true;
VerticalAlignment = VAlignment.Center;
AddChild(_customBulletRenderer = new CustomBulletRenderer(loadedSprite, spentTexture, numberOfRows)
{
HorizontalAlignment = HAlignment.Right,
VerticalAlignment = VAlignment.Bottom,
});
}
public void Update(int count, int capacity)
{
_customBulletRenderer.Capacity = capacity;
_customBulletRenderer.Count = count;
}
}
private sealed class ChamberMagazineStatusControl : Control
{
private readonly BulletRender _bulletRender;

View file

@ -0,0 +1,30 @@
using Content.Shared.Weapons.Ranged.Components;
namespace Content.Client.Weapons.Ranged.Systems;
public sealed partial class GunSystem
{
protected override void InitializeCustomAmmoCounter()
{
base.InitializeCustomAmmoCounter();
SubscribeLocalEvent<CustomSpriteAmmoCounterComponent, UpdateAmmoCounterEvent>(OnAmmoCountUpdate);
SubscribeLocalEvent<CustomSpriteAmmoCounterComponent, AmmoCounterControlEvent>(OnControl);
}
private void OnAmmoCountUpdate(Entity<CustomSpriteAmmoCounterComponent> ent, ref UpdateAmmoCounterEvent args)
{
if (args.Control is not CustomIconStatusControl customIcon)
return;
customIcon.Update(GetAmmoCount(ent), GetAmmoCapacity(ent));
}
private void OnControl(Entity<CustomSpriteAmmoCounterComponent> ent, ref AmmoCounterControlEvent args)
{
var loadedTexture = _sprite.GetFrame(ent.Comp.LoadedAmmoSprite, TimeSpan.Zero);
var spentTexture = _sprite.GetFrame(ent.Comp.SpentAmmoSprite, TimeSpan.Zero);
args.Control = new CustomIconStatusControl(loadedTexture, spentTexture, ent.Comp.NumberOfRows);
}
}

View file

@ -296,14 +296,6 @@ public sealed partial class GunSystem : SharedGunSystem
_recoil.KickCamera(user.Value, recoil.Normalized() * 0.5f * recoilScalar);
}
protected override void Popup(string message, EntityUid? uid, EntityUid? user)
{
if (uid == null || user == null || !Timing.IsFirstTimePredicted)
return;
PopupSystem.PopupEntity(message, uid.Value, user.Value);
}
protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? tracked = null)
{
if (!Timing.IsFirstTimePredicted)
@ -483,14 +475,14 @@ public sealed partial class GunSystem : SharedGunSystem
public int Compare((EntityUid clicked, bool alive, bool occluded, int depth, uint renderOrder, float bottom, float distance) x,
(EntityUid clicked, bool alive, bool occluded, int depth, uint renderOrder, float bottom, float distance) y)
{
var cmp = y.alive.CompareTo(x.alive);
var cmp = y.occluded.CompareTo(x.occluded);
if (cmp != 0)
{
return cmp;
}
cmp = y.occluded.CompareTo(x.occluded);
cmp = y.alive.CompareTo(x.alive);
if (cmp != 0)
{
return cmp;
@ -532,6 +524,7 @@ public sealed partial class GunSystem : SharedGunSystem
if (!Resolve(entity, ref entity.Comp))
return false;
// TODO: Maybe also check that our cursor is intersecting a valid fixture?
foreach (var fix in entity.Comp.Fixtures)
{
if (!fix.Value.Hard || (fix.Value.CollisionLayer & (int)CollisionGroup.BulletImpassable) == 0)

View file

@ -37,11 +37,22 @@ public sealed class LogWindowTest : InteractionTest
var refresh = logWindow.Logs.RefreshButton;
var cont = logWindow.Logs.LogsContainer;
async Task<AdminLogLabel[]> SearchForLog(Guid logGuid)
{
await Client.WaitPost(() => search.Text = logGuid.ToString());
await ClickControl(refresh);
await RunUntilSynced();
await RunTicks(10);
return cont.Children
.Where(x => x.Visible && x is AdminLogLabel)
.Cast<AdminLogLabel>()
.ToArray();
}
// Search for the log we added earlier.
await Client.WaitPost(() => search.Text = guid.ToString());
await ClickControl(refresh);
await RunTicks(10);
var searchResult = cont.Children.Where(x => x.Visible && x is AdminLogLabel).Cast<AdminLogLabel>().ToArray();
var searchResult = await SearchForLog(guid);
Assert.That(searchResult, Has.Length.EqualTo(1));
Assert.That(searchResult[0].Log.Message, Contains.Substring($" test log 1: {guid}"));
@ -50,10 +61,7 @@ public sealed class LogWindowTest : InteractionTest
await Server.WaitPost(() => log.Add(LogType.Unknown, $"{SPlayer} test log 2: {guid}"));
// Update the search and refresh
await Client.WaitPost(() => search.Text = guid.ToString());
await ClickControl(refresh);
await RunTicks(10);
searchResult = cont.Children.Where(x => x.Visible && x is AdminLogLabel).Cast<AdminLogLabel>().ToArray();
searchResult = await SearchForLog(guid);
Assert.That(searchResult, Has.Length.EqualTo(1));
Assert.That(searchResult[0].Log.Message, Contains.Substring($" test log 2: {guid}"));
}

View file

@ -0,0 +1,121 @@
#nullable enable
using System.IO;
using System.Linq;
using Content.Client.Audio.Midi;
using Content.IntegrationTests.Fixtures;
using Robust.Shared.ContentPack;
using Robust.Shared.Utility;
namespace Content.IntegrationTests.Tests.Audio.Midi;
[TestFixture]
public sealed partial class MidiFileCollectionManagerTests : GameTest
{
private static readonly byte[] TestBytes = [1, 2, 3, 4, 5, 6];
private static readonly ResPath TestFileName = new ResPath("unit_test.midi");
private static ResPath TestUserDataDir => new ResPath("/UserMidis/");
private static ResPath TestFullPath => TestUserDataDir / TestFileName;
private IResourceManager ResManager => Pair.Client.ResolveDependency<IResourceManager>();
private MidiFileCollectionManager MidiLibManager => Pair.Client.ResolveDependency<MidiFileCollectionManager>();
[TearDown]
public void CleanUserData()
{
foreach (var file in ResManager.UserData.DirectoryEntries(TestUserDataDir))
{
ResManager.UserData.Delete(new ResPath(TestUserDataDir + file));
}
MidiLibManager.ReloadLibrary();
}
[Test]
public async Task TestAddMidiFile()
{
var addedFileName = new ResPath("");
Stream stream = new MemoryStream(TestBytes);
MidiLibManager.MidiFileAdded += s => { addedFileName = s; };
await MidiLibManager.AddMidiFile(TestFileName, stream);
var outputBytes = ResManager.UserData.ReadAllBytes(TestFullPath);
using (Assert.EnterMultipleScope())
{
Assert.That(MidiLibManager.GetMidiFiles(), Contains.Item(TestFileName));
Assert.That(outputBytes, Is.EqualTo(TestBytes));
Assert.That(addedFileName, Is.EqualTo(TestFileName));
}
}
[Test]
public void TestGetMidiData()
{
ResManager.UserData.WriteAllBytes(TestFullPath, TestBytes);
var midiBytes = MidiLibManager.GetMidiData(TestFileName);
Assert.That(TestBytes, Is.EqualTo(midiBytes));
}
[Test]
public void TestRemoveMidiFile()
{
var removedFileName = new ResPath("");
MidiLibManager.MidiFileRemoved += s => { removedFileName = s; };
ResManager.UserData.WriteAllBytes(TestFullPath, TestBytes);
Assert.That(ResManager.UserData.Exists(TestFullPath), Is.True);
MidiLibManager.RemoveMidiFile(TestFileName);
using (Assert.EnterMultipleScope())
{
Assert.That(ResManager.UserData.Exists(TestFullPath), Is.False);
Assert.That(MidiLibManager.GetMidiFiles(), Is.Empty);
Assert.That(removedFileName, Is.EqualTo(TestFileName));
}
}
[Test]
public async Task TestRemoveAllMidiFiles()
{
var resetFired = false;
MidiLibManager.MidiFilesReset += () => { resetFired = true; };
await MidiLibManager.AddMidiFile(new ResPath("1_unit_test.midi"), TestBytes);
await MidiLibManager.AddMidiFile(new ResPath("2_unit_test.midi"), TestBytes);
await MidiLibManager.AddMidiFile(new ResPath("3_unit_test.midi"), TestBytes);
Assert.That(MidiLibManager.GetMidiFiles().Count(), Is.EqualTo(3));
MidiLibManager.RemoveAllMidiFiles();
using (Assert.EnterMultipleScope())
{
Assert.That(MidiLibManager.GetMidiFiles(), Is.Empty);
Assert.That(resetFired, Is.True);
}
}
[Test]
public void TestRenameMidiFile()
{
var renamedFileName = new ResPath("unit_test_renamed.midi");
var removedFileName = new ResPath("");
var addedFileName = new ResPath("");
MidiLibManager.MidiFileRemoved += s => { removedFileName = s; };
MidiLibManager.MidiFileAdded += s => { addedFileName = s; };
ResManager.UserData.WriteAllBytes(TestFullPath, TestBytes);
Assert.That(ResManager.UserData.Exists(TestFullPath), Is.True);
MidiLibManager.RenameMidiFile(TestFileName, renamedFileName);
using (Assert.EnterMultipleScope())
{
Assert.That(ResManager.UserData.Exists(TestUserDataDir / renamedFileName), Is.True);
Assert.That(ResManager.UserData.Exists(TestFullPath), Is.False);
Assert.That(removedFileName, Is.EqualTo(TestFileName));
Assert.That(addedFileName, Is.EqualTo(renamedFileName));
}
}
}

View file

@ -0,0 +1,213 @@
using Content.Client.UserInterface.Systems.Hotbar.Widgets;
using Content.Client.UserInterface.Systems.Storage.Controls;
using Content.IntegrationTests.NUnit.Constraints;
using Content.IntegrationTests.Fixtures.Attributes;
using Content.IntegrationTests.Tests.Interaction;
using Content.Shared.Changeling.Components;
using Content.Shared.Changeling.Systems;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Storage;
using Content.Shared.Storage.EntitySystems;
using Robust.Client.UserInterface;
using Robust.Shared.Containers;
using Robust.Shared.GameObjects;
using Robust.Shared.Prototypes;
using Content.Shared.Input;
namespace Content.IntegrationTests.Tests.Changeling;
[TestOf(typeof(ChangelingTransformSystem))]
public sealed class ChangelingSlimeTests : InteractionTest
{
protected override string PlayerPrototype => "MobLing";
private static readonly EntProtoId SlimeHumanoidProtoId = "MobSlimePerson";
private static readonly EntProtoId AppleProtoId = "FoodApple";
[SidedDependency(Side.Server)] private SharedChangelingIdentitySystem _changelingIdentity = default!;
[SidedDependency(Side.Server)] private ChangelingTransformSystem _changelingTransform = default!;
[SidedDependency(Side.Server)] private SharedStorageSystem _sharedStorage = default!;
[SidedDependency(Side.Server)] private SharedTransformSystem _transform = default!;
[SidedDependency(Side.Server)] private SharedContainerSystem _container = default!;
[SidedDependency(Side.Server)] private SharedHandsSystem _hands = default!;
public override async Task DoSetup()
{
await base.DoSetup();
// Set up the ling with a slime present and already consumed.
var slime = await SpawnTarget(SlimeHumanoidProtoId);
var slimeEntity = ToServer(slime);
await Server.WaitPost(() =>
{
// Just give the ling the identity of a slime, no need to mess around with devouring, that's on the devour test to handle.
_changelingIdentity.GrantIdentity(SPlayer, slimeEntity);
});
}
[Test]
[Description(
"Test that a changeling transforming into a slime will gain the appropriate storage container and BUI associated with the species and lose them when transforming back into a human.")]
public async Task TransformIntoSlimeTest()
{
Assume.That(_changelingIdentity.TryGetDataFromOriginal(SPlayer,
SPlayer,
out var humanIdentityData), "Failed to get the changeling's human identity data.");
Assume.That(_changelingIdentity.TryGetDataFromOriginal(
SPlayer,
STarget!.Value,
out var slimeIdentity), "Failed to get the changeling's slime identity data.");
// Assert that the player does not have a storage component or BUI before transforming into a slime.
Assert.That(SPlayer, Has.No.Comp<StorageComponent>(Server), "Non-slime player spawned with a storage component.");
Assert.That(CUiSys.TryGetInterfaceData(CPlayer, StorageComponent.StorageUiKey.Key, out _), Is.False, "Non-slime player spawned with a storage BUI.");
Assert.That(CUiSys.TryGetInterfaceData(CPlayer, ChangelingTransformUiKey.Key, out _), Is.True, "Changeling did not spawn with a changeling transform BUI.");
// Transform the player into a slime.
await Server.WaitPost(() =>
{
_changelingTransform.TransformInto(SPlayer, slimeIdentity!.Identity!.Value);
});
await AwaitDoAfters();
// Check storage and BUI presence.
Assert.That(SPlayer, Has.Comp<StorageComponent>(Server), "Changeling did not gain a storage component when transforming into a slime.");
Assert.That(CUiSys.TryGetInterfaceData(CPlayer, StorageComponent.StorageUiKey.Key, out _), Is.True, "Changeling did not gain a storage BUI when transforming into a slime.");
Assert.That(CUiSys.TryGetInterfaceData(CPlayer, ChangelingTransformUiKey.Key, out _), Is.True, "Changeling lost their changeling transform BUI when transforming into a slime.");
// Transform the player back into a human.
await Server.WaitPost(() =>
{
_changelingTransform.TransformInto(SPlayer, humanIdentityData!.Identity!.Value);
});
await AwaitDoAfters();
// Reassert that the storage and BUI presence is back to the original state.
Assert.That(SPlayer, Has.No.Comp<StorageComponent>(Server), "Changeling did not lose their storage component when transforming back into a human.");
// Assert.That(CUiSys.TryGetInterfaceData(CPlayer, StorageComponent.StorageUiKey.Key, out _), Is.False, "Changeling did not lose their storage BUI when transforming back into a human."); // TODO: BUI is not being removed right now.
Assert.That(CUiSys.TryGetInterfaceData(CPlayer, ChangelingTransformUiKey.Key, out _), Is.True, "Changeling lost their changeling transform BUI when transforming back into a human.");
}
[Test]
[Description(
"Test that a changeling transforming between slimes wont lose a storage")]
public async Task TransformPreserveStorage()
{
var lingIdentityComp = Comp<ChangelingIdentityComponent>(Player);
Assume.That(_changelingIdentity.TryGetDataFromOriginal(SPlayer,
STarget!.Value,
out var slimeIdentity1), "Failed to get the changeling's slime identity data.");
// Spawn a second slime.
var secondSlime = await Spawn(SlimeHumanoidProtoId);
var secondSlimeEntity = ToServer(secondSlime);
await Server.WaitAssertion(() =>
{
_changelingIdentity.GrantIdentity(SPlayer, secondSlimeEntity);
Assume.That(lingIdentityComp.ConsumedIdentities, Has.Count.EqualTo(3), "Changeling did not gain the correct number of identities.");
// Transform into the first slime.
_changelingTransform.TransformInto(SPlayer, slimeIdentity1!.Identity!.Value);
});
await AwaitDoAfters();
Assume.That(_changelingIdentity.TryGetDataFromOriginal(SPlayer,
secondSlimeEntity,
out var slimeIdentity2), "Failed to get the changeling's second slime identity data.");
Assert.That(SPlayer, Has.Comp<StorageComponent>(Server), "Changeling did not gain a storage component when transforming into a slime.");
// Spawn a test item in the players hand.
var apple = await PlaceInHands(AppleProtoId);
var appleEnt = ToServer(apple);
// Now insert it into our slime storage.
var storageComponent = Comp<StorageComponent>(Player);
await Server.WaitPost(() =>
{
_sharedStorage.PlayerInsertHeldEntity(SPlayer, SPlayer);
});
Assert.That(storageComponent.StoredItems, Has.Count.EqualTo(1));
Assert.That(storageComponent.StoredItems.TryGetValue(appleEnt, out var appleStoredLocation), "Failed to get the stored location of the apple.");
// Transform into the second slime we added earlier.
await Server.WaitPost(() =>
{
_changelingTransform.TransformInto(SPlayer, slimeIdentity2!.Identity!.Value);
});
await AwaitDoAfters();
// Check that it's the same component from earlier and that the apple is in the same container.
storageComponent = Comp<StorageComponent>(Player);
Assert.That(storageComponent.StoredItems, Has.Count.EqualTo(1), "Changeling lost their storage contents when transforming between slimes.");
Assert.That(_container.TryGetContainingContainer(appleEnt, out var container), "Failed to get the container for the stored item after transforming between slimes.");
Assert.That(container, Is.EqualTo(storageComponent.Container), "The stored item is no longer in the same storage container after transforming between slimes.");
Assert.That(storageComponent.StoredItems.TryGetValue(appleEnt, out var postTransformStoredLocation), "Failed to get the stored location of the apple after transforming between slimes.");
Assert.That(appleStoredLocation, Is.EqualTo(postTransformStoredLocation), "The stored item is no longer in the same location within the storage container after transforming between slimes.");
// Actually pull the apple from the inventory.
await Activate(Player);
Assert.That(IsUiOpen(StorageComponent.StorageUiKey.Key), "Storage BUI did not open when activating the changeling.");
var ctrl = GetStorageControl(apple);
await ClickControl(ctrl, ContentKeyFunctions.MoveStoredItem);
await RunUntilSynced();
Assert.That(_hands.IsHolding((SPlayer, Hands), appleEnt), "Changeling did not successfully pull the stored item from their storage.");
}
[Test]
[Description(
"Test that a changeling transforming out of a slime drops the item inside their storage onto the ground")]
public async Task TransformDropStorage()
{
var transformComponent = Comp<TransformComponent>(Player);
// Set up having an apple inside the slimes storage.
Assume.That(_changelingIdentity.TryGetDataFromOriginal(SPlayer,
STarget!.Value,
out var slimeIdentity), "Failed to get slime identity.");
Assume.That(_changelingIdentity.TryGetDataFromOriginal(SPlayer,
SPlayer,
out var humanIdentity), "Failed to get human identity.");
var apple = await PlaceInHands(AppleProtoId);
var appleEnt = ToServer(apple);
// Transform into a slime.
await Server.WaitPost(() =>
{
_changelingTransform.TransformInto(SPlayer, slimeIdentity!.Identity!.Value);
});
await AwaitDoAfters();
// Insert the apple into the slime storage.
await Server.WaitPost(() =>
{
_sharedStorage.PlayerInsertHeldEntity(SPlayer, SPlayer);
});
var storageComponent = Comp<StorageComponent>(Player);
Assert.That(_container.TryGetContainingContainer(appleEnt, out var container), "Failed to get the container for the stored item after inserting into slime storage.");
Assert.That(container, Is.EqualTo(storageComponent.Container), "The stored item is not in the storage container after inserting into slime storage.");
// Now transform out.
await Server.WaitPost(() =>
{
_changelingTransform.TransformInto(SPlayer, humanIdentity!.Identity!.Value);
});
await AwaitDoAfters();
// Assert that the storage container has been properly removed from the player,
// items have been dumped and the lifestage for the StorageComponent has become Deleted
Assert.That(SPlayer, Has.No.Comp<StorageComponent>(Server));
Assert.That(storageComponent.StoredItems, Has.Count.EqualTo(0));
Assert.That(storageComponent.LifeStage, Is.EqualTo(ComponentLifeStage.Deleted));
// Does the apple still exist?
Assert.That(_container.TryGetContainingContainer(appleEnt, out _), Is.False, "The apple is still in a container after the changeling transformed out of slime form.");
Assert.That(_transform.InRange(SPlayer, appleEnt, 1), Is.True, "The apple is not within range after the changeling transformed out of slime form.");
}
private ItemGridPiece GetStorageControl(NetEntity target)
{
var uid = ToClient(target);
var hotbar = GetWidget<HotbarGui>();
var storageContainer = GetControlFromField<Control>(nameof(HotbarGui.SingleStorageContainer), hotbar);
return GetControlFromChildren<ItemGridPiece>(c => c.Entity == uid, storageContainer);
}
}

View file

@ -0,0 +1,123 @@
using Content.IntegrationTests.Fixtures.Attributes;
using Content.IntegrationTests.Tests.Interaction;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Systems;
using Content.Shared.FixedPoint;
using Content.Shared.Item.ItemToggle.Components;
using Content.Shared.Power.EntitySystems;
using Robust.Shared.GameObjects;
using Robust.Shared.Prototypes;
namespace Content.IntegrationTests.Tests.Chemistry;
[TestOf(typeof(RiggableSystem))]
public sealed class RiggableTest : InteractionTest
{
private static readonly EntProtoId HumanProtoId = "MobHuman";
private static readonly EntProtoId BatteryProto = "PowerCellSmall";
private static readonly EntProtoId FlashlightProto = "EmptyFlashlightLantern";
private static readonly EntProtoId StunbatonProto = "Stunbaton";
[SidedDependency(Side.Server)] private readonly DamageableSystem _damageable = default!;
[TestPrototypes]
private const string Prototypes = @"
- type: entity
parent: PrefilledSyringe
id: TestPlasmaSyringe
components:
- type: Solution
solution:
reagents:
- ReagentId: Plasma
Quantity: 15
- type: entity
parent: PrefilledSyringe
id: TestMilkSyringe
components:
- type: Solution
solution:
reagents:
- ReagentId: Milk
Quantity: 15
";
[Test]
[TestCase("TestPlasmaSyringe", ExpectedResult = true)]
[TestCase("TestMilkSyringe", ExpectedResult = false)]
[Description("Gives the player a power cell, injects it with different solutions and tests the rigged cell in a flashlight")]
public async Task<bool> RigBatteryTest(string syringe)
{
await AddAtmosphere();
await SpawnTarget(HumanProtoId);
Entity<DamageableComponent> mob = (STarget.Value, Comp<DamageableComponent>());
Assert.That(_damageable.GetPositiveDamage(mob).GetTotal(), Is.EqualTo(FixedPoint2.Zero),
"Player spawned with damage.");
var battery = await SpawnTarget(BatteryProto);
// Rig the cell
await PlaceInHands(syringe);
await Interact();
// Put it into a flashlight
await Pickup(battery);
await SpawnTarget(FlashlightProto);
await Interact();
await RunTicks(5);
using (Assert.EnterMultipleScope())
{
Assert.That(HandSys.GetActiveItem((SPlayer, Hands)), Is.Null,
"Battery did not get inserted into the flashlight.");
Assert.That(_damageable.GetPositiveDamage(mob).GetTotal(), Is.EqualTo(FixedPoint2.Zero),
"Player received damage before flashlight activation.");
}
// Turn the flashlight on and observe the result
await Activate();
await RunTicks(5);
if (_damageable.GetPositiveDamage(mob).GetTotal() > FixedPoint2.Zero)
{
// Flashlight exploded
AssertDeleted(battery);
return true;
}
else
{
// Nothing happened
return false;
}
}
[Test]
[Description("Gives the player an activated stunbaton and tests that it explodes immediately on plasma injection")]
public async Task RigActivatedTest()
{
await AddAtmosphere();
await SpawnTarget(HumanProtoId);
Entity<DamageableComponent> mob = (STarget.Value, Comp<DamageableComponent>());
Assert.That(_damageable.GetPositiveDamage(mob).GetTotal(), Is.EqualTo(FixedPoint2.Zero),
"Player spawned with damage.");
var baton = await PlaceInHands(StunbatonProto);
Assert.That(Comp<ItemToggleComponent>(baton).Activated, Is.True, "Stunbaton did not activate");
// Rig the baton
await PlaceInHands("TestPlasmaSyringe");
await Interact();
await RunTicks(5);
using (Assert.EnterMultipleScope())
{
Assert.That(_damageable.GetPositiveDamage(mob).GetTotal(), Is.GreaterThan(FixedPoint2.Zero), "Rigged stunbaton did not explode?");
AssertDeleted(baton);
}
}
}

View file

@ -2,21 +2,21 @@ using Content.Shared.Chemistry.Reaction;
using Content.Shared.Chemistry.Components;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using System.Linq;
using Content.IntegrationTests.Fixtures;
using Content.IntegrationTests.Fixtures.Attributes;
using Content.IntegrationTests.Utility;
using Content.Shared.Chemistry.EntitySystems;
namespace Content.IntegrationTests.Tests.Chemistry
namespace Content.IntegrationTests.Tests.Chemistry;
[TestFixture]
[TestOf(typeof(ReactionPrototype))]
public sealed class TryAllReactionsTest : GameTest
{
[TestFixture]
[TestOf(typeof(ReactionPrototype))]
public sealed class TryAllReactionsTest : GameTest
{
[TestPrototypes]
private const string Prototypes = @"
[TestPrototypes]
private const string Prototypes = @"
- type: entity
id: TestSolutionContainer
components:
@ -25,115 +25,124 @@ namespace Content.IntegrationTests.Tests.Chemistry
solution:
maxVol: 120";
private static string[] _reactions = GameDataScrounger.PrototypesOfKind<ReactionPrototype>();
private static readonly string[] Reactions = GameDataScrounger.PrototypesOfKind<ReactionPrototype>();
[Test]
[TestCaseSource(nameof(_reactions))]
[TestOf(typeof(ReactionPrototype))]
[Description("Tries an individual reaction to see if it succeeds.")]
public async Task TryReaction(string reaction)
[SidedDependency(Side.Server)] private readonly SharedSolutionContainerSystem _solutionContainerSystem = default!;
[Test]
[TestOf(typeof(ReactionPrototype))]
[Description("Tries an individual reaction to see if it succeeds.")]
public async Task TryReaction()
{
var testMap = await Pair.CreateTestMap();
var coordinates = testMap.GridCoords;
// they call me a bird the way i be nesting
try
{
var pair = Pair;
var server = pair.Server;
var entityManager = server.ResolveDependency<IEntityManager>();
var prototypeManager = server.ResolveDependency<IPrototypeManager>();
var testMap = await pair.CreateTestMap();
var coordinates = testMap.GridCoords;
var solutionContainerSystem = entityManager.System<SharedSolutionContainerSystem>();
var reactionPrototype = prototypeManager.Index<ReactionPrototype>(reaction);
EntityUid beaker = default;
Solution solution = null;
Entity<SolutionComponent>? solutionEnt = default!;
await server.WaitAssertion(() =>
{
beaker = entityManager.SpawnEntity("TestSolutionContainer", coordinates);
Assert.That(solutionContainerSystem
.TryGetSolution(beaker, "beaker", out solutionEnt, out solution));
solutionContainerSystem.SetCanReact(solutionEnt!.Value, false);
foreach (var (id, reactant) in reactionPrototype.Reactants)
{
#pragma warning disable NUnit2045
Assert.That(solutionContainerSystem
.TryAddReagent(solutionEnt.Value,
id,
reactant.Amount,
out var quantity,
reactionPrototype.MinimumTemperature));
Assert.That(reactant.Amount, Is.EqualTo(quantity));
#pragma warning restore NUnit2045
}
//Get all possible reactions with the current reagents
var possibleReactions = prototypeManager.EnumeratePrototypes<ReactionPrototype>()
.Where(x => x.Reactants.All(id => solution.Contents.Any(s => s.Reagent.Prototype == id.Key)))
.ToList();
//Check if the reaction is the first to occur when heated
foreach (var possibleReaction in possibleReactions.OrderBy(r => r.MinimumTemperature))
{
if (possibleReaction.Priority >= reactionPrototype.Priority &&
possibleReaction.MinimumTemperature < reactionPrototype.MinimumTemperature &&
possibleReaction.MixingCategories == reactionPrototype.MixingCategories)
{
Assert.Fail(
$"The {possibleReaction.ID} reaction may occur before {reactionPrototype.ID} when heated.");
}
}
//Check if the reaction is the first to occur when freezing
foreach (var possibleReaction in possibleReactions.OrderBy(r => r.MaximumTemperature))
{
if (possibleReaction.Priority >= reactionPrototype.Priority &&
possibleReaction.MaximumTemperature > reactionPrototype.MaximumTemperature &&
possibleReaction.MixingCategories == reactionPrototype.MixingCategories)
{
Assert.Fail(
$"The {possibleReaction.ID} reaction may occur before {reactionPrototype.ID} when freezing.");
}
}
//Now safe set the temperature and mix the reagents
solutionContainerSystem.SetTemperature(solutionEnt.Value, reactionPrototype.MinimumTemperature);
solutionContainerSystem.SetCanReact(solutionEnt.Value, true);
if (reactionPrototype.MixingCategories != null)
{
var dummyEntity = entityManager.SpawnEntity(null, MapCoordinates.Nullspace);
var mixerComponent = entityManager.AddComponent<ReactionMixerComponent>(dummyEntity);
mixerComponent.ReactionTypes = reactionPrototype.MixingCategories;
solutionContainerSystem.UpdateChemicals(solutionEnt.Value, true, mixerComponent);
}
});
await server.WaitIdleAsync();
await server.WaitAssertion(() =>
foreach (var reaction in Reactions)
{
//you just got linq'd fool
//(i'm sorry)
var foundProductsMap = reactionPrototype.Products
.Concat(reactionPrototype.Reactants
.Where(x => x.Value.Catalyst)
.ToDictionary(x => x.Key, x => x.Value.Amount)
)
.ToDictionary(x => x, _ => false);
var reactionPrototype = SProtoMan.Index<ReactionPrototype>(reaction);
foreach (var (reagent, quantity) in solution.Contents)
EntityUid beaker = default;
Solution solution = null;
try
{
Assert.That(foundProductsMap.TryFirstOrNull(
x => x.Key.Key == reagent.Prototype && x.Key.Value == quantity,
out var foundProduct));
foundProductsMap[foundProduct!.Value.Key] = true;
await Pair.Server.WaitAssertion(() =>
{
beaker = SEntMan.SpawnEntity("TestSolutionContainer", coordinates);
Assert.That(_solutionContainerSystem
.TryGetSolution(beaker, "beaker", out var solutionEnt, out solution));
_solutionContainerSystem.SetCanReact(solutionEnt!.Value, false);
foreach (var (id, reactant) in reactionPrototype.Reactants)
{
Assert.That(_solutionContainerSystem
.TryAddReagent(solutionEnt.Value,
id,
reactant.Amount,
out var quantity,
reactionPrototype.MinimumTemperature));
Assert.That(reactant.Amount, Is.EqualTo(quantity));
}
//Get all possible reactions with the current reagents
var possibleReactions = SProtoMan.EnumeratePrototypes<ReactionPrototype>()
.Where(x => x.Reactants.All(id =>
solution.Contents.Any(s => s.Reagent.Prototype == id.Key)))
.ToList();
//Check if the reaction is the first to occur when heated
foreach (var possibleReaction in possibleReactions.OrderBy(r => r.MinimumTemperature))
{
if (possibleReaction.Priority >= reactionPrototype.Priority &&
possibleReaction.MinimumTemperature < reactionPrototype.MinimumTemperature &&
possibleReaction.MixingCategories == reactionPrototype.MixingCategories)
{
Assert.Fail(
$"The {possibleReaction.ID} reaction may occur before {reactionPrototype.ID} when heated.");
}
}
//Check if the reaction is the first to occur when freezing
foreach (var possibleReaction in possibleReactions.OrderBy(r => r.MaximumTemperature))
{
if (possibleReaction.Priority >= reactionPrototype.Priority &&
possibleReaction.MaximumTemperature > reactionPrototype.MaximumTemperature &&
possibleReaction.MixingCategories == reactionPrototype.MixingCategories)
{
Assert.Fail(
$"The {possibleReaction.ID} reaction may occur before {reactionPrototype.ID} when freezing.");
}
}
//Now safe set the temperature and mix the reagents
_solutionContainerSystem.SetTemperature(solutionEnt.Value,
reactionPrototype.MinimumTemperature);
_solutionContainerSystem.SetCanReact(solutionEnt.Value, true);
if (reactionPrototype.MixingCategories != null)
{
var dummyEntity = SEntMan.SpawnEntity(null, MapCoordinates.Nullspace);
var mixerComponent = SEntMan.AddComponent<ReactionMixerComponent>(dummyEntity);
mixerComponent.ReactionTypes = reactionPrototype.MixingCategories;
_solutionContainerSystem.UpdateChemicals(solutionEnt.Value, true, mixerComponent);
}
});
await Pair.Server.WaitIdleAsync();
await Pair.Server.WaitAssertion(() =>
{
//you just got linq'd fool
//(i'm sorry)
var foundProductsMap = reactionPrototype.Products
.Concat(reactionPrototype.Reactants
.Where(x => x.Value.Catalyst)
.ToDictionary(x => x.Key, x => x.Value.Amount)
)
.ToDictionary(x => x, _ => false);
foreach (var (reagent, quantity) in solution.Contents)
{
Assert.That(foundProductsMap.TryFirstOrNull(
x => x.Key.Key == reagent.Prototype && x.Key.Value == quantity,
out var foundProduct));
foundProductsMap[foundProduct!.Value.Key] = true;
}
Assert.That(foundProductsMap.All(x => x.Value));
});
}
Assert.That(foundProductsMap.All(x => x.Value));
server.EntMan.DeleteEntity(beaker);
});
finally
{
await Server.WaitPost(() => SEntMan.DeleteEntity(beaker));
}
}
}
finally
{
await Server.WaitPost(() => SEntMan.DeleteEntity(testMap.MapUid));
}
}
}

View file

@ -1,5 +1,3 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using Content.IntegrationTests.Fixtures;
using Content.IntegrationTests.Fixtures.Attributes;
using Content.IntegrationTests.Utility;
@ -8,7 +6,6 @@ using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Damage.Systems;
using Content.Shared.FixedPoint;
using Robust.Shared.Map;
namespace Content.IntegrationTests.Tests.Damageable;
@ -19,42 +16,60 @@ public sealed class DamageAllPrototypesTest : GameTest
{
[SidedDependency(Side.Server)] private readonly DamageableSystem _damageableSystem = default!;
private static string[] _damageables = GameDataScrounger.EntitiesWithComponent("Damageable");
[Test]
[TestOf(typeof(DamageableSystem))]
[TestCaseSource(nameof(_damageables))]
[Description("Ensures all Entity Prototypes with damageable can be damaged.")]
public async Task TestDamageableComponents(string damageable)
public async Task TestDamageableComponents()
{
var map = await Pair.CreateTestMap();
var entity = await SpawnAtPosition(damageable, map.GridCoords);
// Intentionally cannot take damage, ignore it.
if (SEntMan.HasComponent<GodmodeComponent>(entity))
return;
var canBeDamaged = false;
foreach (var type in SProtoMan.EnumeratePrototypes<DamageTypePrototype>())
try
{
if (!_damageableSystem.CanBeDamagedBy(entity, type))
continue;
canBeDamaged = true;
await Server.WaitPost(() =>
foreach (var damageable in GameDataScrounger.EntitiesWithComponent("Damageable"))
{
var damage = new DamageSpecifier(type, FixedPoint2.Epsilon);
var previousDamage = _damageableSystem.GetTotalDamage(entity);
_damageableSystem.ChangeDamage(entity, damage, ignoreResistances: true);
Assert.That(_damageableSystem.GetTotalDamage(entity) == FixedPoint2.Epsilon + previousDamage);
_damageableSystem.ClearAllDamage(entity);
});
}
var entity = await SpawnAtPosition(damageable, map.GridCoords);
// Ensure that this entity can actually be damaged.
Assert.That(canBeDamaged);
try
{
// Intentionally cannot take damage, ignore it.
if (SEntMan.HasComponent<GodmodeComponent>(entity))
continue;
var canBeDamaged = false;
foreach (var type in SProtoMan.EnumeratePrototypes<DamageTypePrototype>())
{
if (!_damageableSystem.CanBeDamagedBy(entity, type))
continue;
canBeDamaged = true;
await Server.WaitAssertion(() =>
{
var damage = new DamageSpecifier(type, FixedPoint2.Epsilon);
var previousDamage = _damageableSystem.GetTotalDamage(entity);
_damageableSystem.ChangeDamage(entity, damage, ignoreResistances: true);
Assert.That(
_damageableSystem.GetTotalDamage(entity),
Is.EqualTo(FixedPoint2.Epsilon + previousDamage),
$"{damageable} should take {type.ID} damage.");
_damageableSystem.ClearAllDamage(entity);
});
}
// Ensure that this entity can actually be damaged.
Assert.That(canBeDamaged, Is.True, $"{damageable} cannot be damaged by any damage type.");
}
finally
{
await Server.WaitPost(() => SEntMan.DeleteEntity(entity));
}
}
}
finally
{
await Server.WaitPost(() => SEntMan.DeleteEntity(map.MapUid));
}
}
}

View file

@ -0,0 +1,69 @@
using Content.IntegrationTests.Fixtures.Attributes;
using Content.IntegrationTests.Tests.Interaction;
using Content.Shared.Bed.Sleep;
using Content.Shared.Eye.Blinding.Components;
using Robust.Shared.Prototypes;
namespace Content.IntegrationTests.Tests.Eye;
/// <summary>
/// Tests for things that directly cause blindness unrelated to eye damage
/// </summary>
public sealed class BlindnessTests : InteractionTest
{
// We need eye slots (and eyeballs) to blind/damage
protected override string PlayerPrototype => "MobHuman";
private static readonly EntProtoId BlindfoldPrototype = "ClothingEyesBlindfold";
[SidedDependency(Side.Server)] private readonly SleepingSystem _sleeping = default!;
/// <summary>
/// Tests that equipping and removing a blindfold adds and removes blindness, respectively
/// </summary>
[Test]
public async Task BlindfoldBlindnessTest()
{
var blindableComponent = Comp<BlindableComponent>(Player);
Assert.That(blindableComponent.IsBlind, Is.False, "Initial blind check failed");
// Grab blindfold, use it, and wait for the DoAfter
var blindfold = await PlaceInHands(BlindfoldPrototype);
await UseInHand();
await AwaitDoAfters();
Assert.That(blindableComponent.IsBlind, Is.True, "Blindfold did not cause blindness");
//Now we remove the blindfold and test that IsBlind isn't set
await Delete(blindfold);
Assert.That(blindableComponent.IsBlind, Is.False, "Removing blindfold did not remove blindness");
}
/// <summary>
/// Tests that falling asleep causes blindness
/// </summary>
[Test]
public async Task SleepBlindnessTest()
{
var blindableComponent = Comp<BlindableComponent>(Player);
// Sleeping inflicts blindness
Assert.That(blindableComponent.IsBlind, Is.False, "Initial blind check failed");
await Server.WaitPost(() =>
{
_sleeping.TrySleeping(SPlayer);
});
Assert.That(blindableComponent.IsBlind, Is.True, "Sleeping did not cause blindness");
// Waking up removes blindness
await Server.WaitPost(() =>
{
_sleeping.TryWaking(SPlayer);
});
Assert.That(blindableComponent.IsBlind, Is.False, "Waking up did not remove blindness");
}
}

View file

@ -0,0 +1,67 @@
using Content.IntegrationTests.Fixtures.Attributes;
using Content.IntegrationTests.Tests.Interaction;
using Content.Shared.Eye.Blinding.Components;
using Content.Shared.Eye.Blinding.Systems;
using Robust.Shared.Prototypes;
namespace Content.IntegrationTests.Tests.Eye;
/// <summary>
/// Tests for eye damage
/// </summary>
public sealed class EyeDamageTests : InteractionTest
{
// We need eye slots (and eyeballs) to blind/damage
protected override string PlayerPrototype => "MobHuman";
private static readonly EntProtoId LockerPrototype = "LockerFreezer";
private static readonly EntProtoId WeldingMaskPrototype = "ClothingHeadHatWelding";
private const int ExpectedWelderEyeDamage = 1; // 1 is a magic number in EyeProtectionSystem.cs. Test will fail if this is changed at some point.
[SidedDependency(Side.Server)] private readonly BlindableSystem _blindable = default!;
/// <summary>
/// Tests that applying maximum eye damage triggers blindness.
/// </summary>
[Test]
public async Task EyeDamageBlindsTest()
{
var blindableComponent = Comp<BlindableComponent>(Player);
Assert.That(blindableComponent.IsBlind, Is.False, "Initial blind check failed");
// Max eye damage inflicts blindness
await Server.WaitPost(() =>
{
_blindable.AdjustEyeDamage(SPlayer, blindableComponent.MaxDamage);
});
Assert.That(blindableComponent.IsBlind, Is.True, "Max eye damage did not inflict blindness");
}
/// <summary>
/// Tests that welding without protection causes eye damage, and welding with protection does not.
/// </summary>
[Test]
public async Task WelderCausesEyeDamageTest()
{
var blindableComponent = Comp<BlindableComponent>(Player);
await SpawnTarget(LockerPrototype);
// Welding without protection causes eye damage
Assert.That(blindableComponent.EyeDamage, Is.Zero, "Initial eye damage >0");
await InteractUsing(Weld);
Assert.That(blindableComponent.EyeDamage, Is.EqualTo(ExpectedWelderEyeDamage), "Unexpected eye damage amount");
// Welding with protection prevents eye damage
var initialEyeDamage = blindableComponent.EyeDamage;
await PlaceInHands(WeldingMaskPrototype);
await UseInHand();
await AwaitDoAfters();
await InteractUsing(Weld);
Assert.That(blindableComponent.EyeDamage, Is.EqualTo(initialEyeDamage), "Eye damage inflicted despite protection");
}
}

Some files were not shown because too many files have changed in this diff Show more