Фотоапарат в КПК

This commit is contained in:
Vigers Ray 2026-01-29 18:56:05 +01:00
parent 416c0ed8bd
commit fcc2b10df5
55 changed files with 2030 additions and 153 deletions

View file

@ -40,14 +40,26 @@
StyleClasses="LabelSubText" />
</BoxContainer>
</BoxContainer>
<Control
<BoxContainer
Orientation="Vertical"
HorizontalExpand="True"
MinSize="0,20"
Name="ContentContainer">
<RichTextLabel
Name="ContentLabel"
HorizontalExpand="True" />
<ContainerButton
Name="ImageButton"
HorizontalExpand="True"
MinSize="0,20" />
Visible="False"
Margin="0,4,0,0">
<PanelContainer Name="ImageBorder" HorizontalExpand="True">
<TextureRect
Name="ImagePreview"
HorizontalExpand="True"
Stretch="KeepAspect" />
</PanelContainer>
</ContainerButton>
</BoxContainer>
<TextureRect
Name="ReadStatusIcon"
SetWidth="16"
@ -57,6 +69,5 @@
VerticalAlignment="Bottom"
Margin="0,0,2,2"
Visible="False" />
</Control>
</BoxContainer>
</PanelContainer>

View file

@ -1,3 +1,4 @@
using System.Numerics;
using Content.Client._Sunrise.Messenger;
using Content.Client.Resources;
using Content.Client.Stylesheets;
@ -8,6 +9,7 @@ using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.RichText;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
@ -21,6 +23,7 @@ public sealed partial class MessagePanel : PanelContainer
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
private ClientEmojiSystem? EmojiSystem => _entitySystemManager.GetEntitySystemOrNull<ClientEmojiSystem>();
private SpriteSystem GetSpriteSystem() => _entitySystemManager.GetEntitySystem<SpriteSystem>();
@ -45,10 +48,36 @@ public sealed partial class MessagePanel : PanelContainer
{
IoCManager.InjectDependencies(this);
RobustXamlLoader.Load(this);
_netTexturesManager.ResourceLoaded += OnResourceLoaded;
ImageButton.OnPressed += _ => ShowFullImage();
ImageBorder.PanelOverride = new StyleBoxFlat
{
BackgroundColor = Color.Transparent,
BorderColor = Color.Transparent,
BorderThickness = new Thickness(2)
};
ImageButton.OnMouseEntered += _ =>
{
if (ImageBorder.PanelOverride is StyleBoxFlat style)
{
style.BorderColor = Color.White.WithAlpha(0.6f);
}
};
ImageButton.OnMouseExited += _ =>
{
if (ImageBorder.PanelOverride is StyleBoxFlat style)
{
style.BorderColor = Color.Transparent;
}
};
}
public event Action<long>? OnDeleteMessage;
private Button? _deleteButton;
private string? _currentImagePath;
public void UpdateMessage(MessengerMessage message, bool isOwnMessage, bool isPersonalChat, string? currentUserId)
{
@ -60,6 +89,7 @@ public sealed partial class MessagePanel : PanelContainer
var parsedContent = EmojiSystem?.ParseEmojis(message.Content) ?? message.Content;
ContentLabel.SetMessage(FormattedMessage.FromMarkupPermissive(parsedContent), MessageTagsAllowed);
ContentLabel.Visible = !string.IsNullOrWhiteSpace(message.Content);
if (isOwnMessage && _deleteButton == null)
{
@ -116,6 +146,83 @@ public sealed partial class MessagePanel : PanelContainer
{
ReadStatusIcon.Visible = false;
}
UpdateImagePreview(message.ImagePath);
}
private void UpdateImagePreview(string? imagePath)
{
if (string.IsNullOrWhiteSpace(imagePath))
{
ImageButton.Visible = false;
_currentImagePath = null;
return;
}
_currentImagePath = imagePath;
var isAvailable = _netTexturesManager.EnsureResource(imagePath);
if (isAvailable)
{
LoadImageTexture(imagePath);
}
else
{
ImageButton.Visible = false;
}
}
private void ShowFullImage()
{
if (ImagePreview.Texture == null)
return;
var window = new DefaultWindow
{
Title = Loc.GetString("messenger-image-preview-title"),
MinSize = new Vector2(600, 600)
};
var textureRect = new TextureRect
{
Texture = ImagePreview.Texture,
Stretch = TextureRect.StretchMode.KeepAspect,
HorizontalExpand = true,
VerticalExpand = true
};
window.Contents.AddChild(textureRect);
window.OpenCentered();
}
private void OnResourceLoaded(string resourcePath)
{
if (_currentImagePath == resourcePath)
{
LoadImageTexture(resourcePath);
}
}
private void LoadImageTexture(string imagePath)
{
try
{
var uploadedPath = _netTexturesManager.GetUploadedPath(imagePath);
if (_resourceCache.TryGetResource<TextureResource>(uploadedPath, out var textureResource))
{
ImagePreview.Texture = textureResource.Texture;
ImageButton.Visible = true;
}
else
{
ImageButton.Visible = false;
}
}
catch
{
ImageButton.Visible = false;
}
}
/// <summary>

View file

@ -17,8 +17,8 @@ public sealed partial class MessengerUi : UIFragment
public override void Setup(BoundUserInterface userInterface, EntityUid? fragmentOwner)
{
_fragment = new MessengerUiFragment();
_fragment.OnSendMessage += (recipientId, groupId, content) =>
SendMessengerMessage(MessengerUiAction.SendMessage, userInterface, recipientId: recipientId, groupId: groupId, content: content);
_fragment.OnSendMessage += (recipientId, groupId, content, imagePath) =>
SendMessengerMessage(MessengerUiAction.SendMessage, userInterface, recipientId: recipientId, groupId: groupId, content: content, imagePath: imagePath);
_fragment.OnCreateGroup += (groupName) =>
SendMessengerMessage(MessengerUiAction.CreateGroup, userInterface, groupName: groupName);
_fragment.OnAddToGroup += (groupId, userId) =>
@ -39,6 +39,8 @@ public sealed partial class MessengerUi : UIFragment
SendMessengerMessage(MessengerUiAction.DeleteMessage, userInterface, chatId: chatId, messageId: messageId);
_fragment.OnTogglePin += (chatId) =>
SendMessengerMessage(MessengerUiAction.TogglePin, userInterface, chatId: chatId);
_fragment.OnRequestPhotos += () =>
SendMessengerMessage(MessengerUiAction.RequestPhotos, userInterface);
}
public override void UpdateState(BoundUserInterfaceState state)
@ -59,9 +61,10 @@ public sealed partial class MessengerUi : UIFragment
string? userId = null,
string? chatId = null,
bool? isMuted = null,
long? messageId = null)
long? messageId = null,
string? imagePath = null)
{
var messengerMessage = new MessengerUiMessageEvent(action, recipientId, groupId, content, groupName, userId, chatId, isMuted, messageId);
var messengerMessage = new MessengerUiMessageEvent(action, recipientId, groupId, content, groupName, userId, chatId, isMuted, messageId, imagePath);
var message = new CartridgeUiMessage(messengerMessage);
userInterface.SendMessage(message);
}

View file

@ -149,6 +149,12 @@
HorizontalExpand="True"
Name="MessageInput"
PlaceHolder="{Loc 'messenger-message-placeholder'}" />
<Button
Name="PhotoButton"
Text="▣"
SetWidth="30"
Margin="4,0,0,0"
ToolTip="{Loc 'messenger-photo-button-tooltip'}" />
<Button
Name="EmojiButton"
Text="☻"

View file

@ -19,13 +19,14 @@ using Robust.Shared.Input;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using Content.Shared.StatusIcon;
using Robust.Shared.Timing;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
[GenerateTypedNameReferences]
public sealed partial class MessengerUiFragment : BoxContainer
{
public event Action<string?, string?, string>? OnSendMessage;
public event Action<string?, string?, string, string?>? OnSendMessage;
public event Action<string>? OnCreateGroup;
public event Action<string, string>? OnAddToGroup;
public event Action<string, string>? OnRemoveFromGroup;
@ -36,6 +37,7 @@ public sealed partial class MessengerUiFragment : BoxContainer
public event Action<string>? OnLeaveGroup;
public event Action<string, long>? OnDeleteMessage;
public event Action<string>? OnTogglePin;
public event Action? OnRequestPhotos;
private string? _currentChatId;
private MessengerUiState? _currentState;
@ -62,6 +64,8 @@ public sealed partial class MessengerUiFragment : BoxContainer
[Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!;
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
private CreateGroupDialog? _createGroupDialog;
private AddUserDialog? _addUserDialog;
@ -99,6 +103,7 @@ public sealed partial class MessengerUiFragment : BoxContainer
CreateGroupButton.OnPressed += _ => ShowCreateGroupDialog();
ToggleMembersButton.OnPressed += _ => ToggleMembersList();
EmojiButton.OnPressed += _ => ShowEmojiPicker();
PhotoButton.OnPressed += _ => RequestPhotos();
SearchInput.OnTextChanged += OnSearchTextChanged;
PersonalChatsTab.OnPressed += _ => SwitchTab(0);
GroupChatsTab.OnPressed += _ => SwitchTab(1);
@ -168,6 +173,12 @@ public sealed partial class MessengerUiFragment : BoxContainer
MessageInput.Editable = canInteract && hasChatSelected;
SendButton.Disabled = !canInteract || !hasChatSelected;
EmojiButton.Disabled = !canInteract || !hasChatSelected;
PhotoButton.Disabled = !canInteract || !hasChatSelected;
if (state.PhotoGallery != null && state.PhotoGallery.Count > 0)
{
ShowPhotoPicker(state.PhotoGallery);
}
var savedChatId = _currentChatId;
var savedGroupId = _currentGroupId;
@ -1143,10 +1154,92 @@ public sealed partial class MessengerUiFragment : BoxContainer
}
MessageInput.Clear();
OnSendMessage?.Invoke(recipientId, groupId, messageText);
OnSendMessage?.Invoke(recipientId, groupId, messageText, null);
ScrollToBottom();
}
private void SendPhoto(string imagePath)
{
if (_currentChatId == null)
return;
string? recipientId = null;
string? groupId = null;
if (_currentChatId.StartsWith("personal_"))
{
var parts = _currentChatId.Split('_');
if (parts.Length >= 3 && _currentState?.CurrentUserId != null)
{
var userId1 = parts[1];
var userId2 = parts[2];
recipientId = userId1 == _currentState.CurrentUserId ? userId2 : userId1;
}
}
else if (_currentState?.Groups.Any(g => g.GroupId == _currentChatId) ?? false)
{
groupId = _currentChatId;
}
OnSendMessage?.Invoke(recipientId, groupId, string.Empty, imagePath);
}
private void RequestPhotos()
{
if (_currentChatId == null)
return;
OnRequestPhotos?.Invoke();
}
private DefaultWindow? _photoPickerDialog;
private void ShowPhotoPicker(Dictionary<string, PhotoMetadata> photos)
{
if (_photoPickerDialog != null && _photoPickerDialog.IsOpen)
return;
var dialog = new DefaultWindow
{
Title = Loc.GetString("messenger-photo-picker-title"),
MinSize = new Vector2(560, 620),
};
_photoPickerDialog = dialog;
var scroll = new ScrollContainer
{
HorizontalExpand = true,
VerticalExpand = true
};
var grid = new GridContainer
{
Columns = 2,
HorizontalExpand = true
};
foreach (var (id, metadata) in photos)
{
var photoControl = new PhotoItemControl(id, metadata,
_netTexturesManager,
_resourceCache,
_gameTiming);
photoControl.MinSize = new Vector2(120, 120);
photoControl.OnPressed += _ =>
{
SendPhoto(metadata.ImagePath);
dialog.Close();
};
grid.AddChild(photoControl);
}
scroll.AddChild(grid);
dialog.Contents.AddChild(scroll);
dialog.OnClose += () => _photoPickerDialog = null;
dialog.OpenCentered();
}
private string GetPersonalChatId(string userId)
{
if (_currentState?.CurrentUserId == null)

View file

@ -0,0 +1,79 @@
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Client.Player;
using Robust.Client.State;
using Robust.Shared.Enums;
using Content.Client.Viewport;
using Robust.Client.UserInterface;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
public sealed class PhotoCaptureOverlay : Overlay
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IStateManager _stateManager = default!;
[Dependency] private readonly IEyeManager _eyeManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
private readonly SharedTransformSystem _transformSystem;
public override OverlaySpace Space => OverlaySpace.ScreenSpace;
public PhotoCaptureOverlay()
{
IoCManager.InjectDependencies(this);
_transformSystem = _entityManager.System<SharedTransformSystem>();
}
protected override void Draw(in OverlayDrawArgs args)
{
if (_stateManager.CurrentState is not IMainViewportState viewportState)
return;
if (args.Viewport.Eye != _eyeManager.CurrentEye)
return;
var screenHandle = args.ScreenHandle;
var viewportControl = (Control)viewportState.Viewport;
var overlaySystem = _entityManager.System<PhotoOverlaySystem>();
var photoSystem = _entityManager.System<PhotoCartridgeClientSystem>();
var source = overlaySystem.ActiveCameraEntity;
if (source == null || !_entityManager.EntityExists(source.Value))
{
if (_playerManager.LocalSession?.AttachedEntity is not { } player)
return;
source = player;
}
var sourcePos = _transformSystem.GetWorldPosition(source.Value);
var targetPos = photoSystem.GetCameraPosition(source.Value, photoSystem.CaptureDistance);
var targetScreen = _eyeManager.WorldToScreen(targetPos);
var playerScreen = _eyeManager.WorldToScreen(sourcePos);
var offsetScreen = _eyeManager.WorldToScreen(sourcePos + new Vector2(1, 0));
var pixelsPerMeter = (offsetScreen - playerScreen).Length();
if (pixelsPerMeter < 1) return;
var halfSize = (3.0f * pixelsPerMeter) / 2.0f;
var rect = new UIBox2(targetScreen.X - halfSize, targetScreen.Y - halfSize, targetScreen.X + halfSize, targetScreen.Y + halfSize);
var vpRect = viewportControl.GlobalPixelRect;
var color = Color.Black.WithAlpha(0.5f);
screenHandle.DrawRect(new UIBox2(vpRect.Left, vpRect.Top, vpRect.Right, rect.Top), color);
screenHandle.DrawRect(new UIBox2(vpRect.Left, rect.Bottom, vpRect.Right, vpRect.Bottom), color);
screenHandle.DrawRect(new UIBox2(vpRect.Left, rect.Top, rect.Left, rect.Bottom), color);
screenHandle.DrawRect(new UIBox2(rect.Right, rect.Top, vpRect.Right, rect.Bottom), color);
screenHandle.DrawRect(rect, Color.Red.WithAlpha(0.3f), false);
var borderThickness = 2f;
screenHandle.DrawRect(new UIBox2(rect.Left, rect.Top, rect.Right, rect.Top + borderThickness), Color.Red);
screenHandle.DrawRect(new UIBox2(rect.Left, rect.Bottom - borderThickness, rect.Right, rect.Bottom), Color.Red);
screenHandle.DrawRect(new UIBox2(rect.Left, rect.Top, rect.Left + borderThickness, rect.Bottom), Color.Red);
screenHandle.DrawRect(new UIBox2(rect.Right - borderThickness, rect.Top, rect.Right, rect.Bottom), Color.Red);
}
}

View file

@ -0,0 +1,203 @@
using System.IO;
using System.Linq;
using System.Numerics;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Client.Viewport;
using Content.Shared.Mobs.Components;
using Robust.Client.Graphics;
using Robust.Client.State;
using Robust.Shared.Network;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Robust.Client.Player;
using Robust.Client.UserInterface;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Map.Components;
using Robust.Shared.Timing;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
public sealed class PhotoCartridgeClientSystem : EntitySystem
{
[Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IStateManager _stateManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly IEyeManager _eyeManager = default!;
[Dependency] private readonly IGameTiming _timing = default!;
private TimeSpan _nextCaptureTime = TimeSpan.Zero;
public bool CameraReady => _timing.CurTime >= _nextCaptureTime;
public float CaptureDistance { get; set; } = 2.0f;
private ISawmill _sawmill = default!;
private const int TargetPhotoWidth = 256;
private const int TargetPhotoHeight = 256;
public override void Initialize()
{
_sawmill = _logManager.GetSawmill("photo.cartridge.client");
_netManager.RegisterNetMessage<PdaPhotoCaptureMessage>(accept: NetMessageAccept.Server);
}
public override void Shutdown()
{
}
public Vector2 GetCameraPosition(EntityUid source, float distance)
{
if (!_entityManager.TryGetComponent<TransformComponent>(source, out var xform))
return Vector2.Zero;
var sourcePos = _transformSystem.GetWorldPosition(source);
var lookRotation = _transformSystem.GetWorldRotation(source);
var ignoreEnt = source;
if (xform.ParentUid.IsValid() && !_entityManager.HasComponent<MapComponent>(xform.ParentUid) && !_entityManager.HasComponent<MapGridComponent>(xform.ParentUid))
{
lookRotation = _transformSystem.GetWorldRotation(xform.ParentUid);
ignoreEnt = xform.ParentUid;
}
var gridRot = Angle.Zero;
if (xform.GridUid != null)
gridRot = _transformSystem.GetWorldRotation(xform.GridUid.Value);
var relativeRot = lookRotation - gridRot;
var directionVec = gridRot.RotateVec(relativeRot.GetCardinalDir().ToVec());
var ray = new CollisionRay(sourcePos, directionVec, (int) Content.Shared.Physics.CollisionGroup.Opaque);
var results = _physics.IntersectRayWithPredicate(xform.MapID, ray, distance, uid =>
{
return uid == source ||
uid == ignoreEnt ||
_entityManager.HasComponent<MobStateComponent>(uid);
}, false).ToList();
if (results.Count > 0)
{
return sourcePos + directionVec * Math.Max(0.1f, results[0].Distance - 0.2f);
}
return sourcePos + directionVec * distance;
}
public void CaptureAndSendPhoto(EntityUid source)
{
if (_timing.CurTime < _nextCaptureTime)
return;
_nextCaptureTime = _timing.CurTime + TimeSpan.FromSeconds(1.5);
Timer.Spawn(500, () => CaptureInternal(source));
}
private void CaptureInternal(EntityUid source)
{
if (!_netManager.IsConnected)
{
_sawmill.Warning("Cannot capture photo: client not connected to server");
return;
}
if (_playerManager.LocalPlayer?.ControlledEntity is not { } player)
return;
if (_stateManager.CurrentState is not IMainViewportState viewportState || viewportState.Viewport is not Control control)
return;
var viewport = viewportState.Viewport.Viewport;
var sourcePos = _transformSystem.GetWorldPosition(source);
var targetPos = GetCameraPosition(source, CaptureDistance);
var targetScreen = _eyeManager.WorldToScreen(targetPos);
var sourceScreen = _eyeManager.WorldToScreen(sourcePos);
var offsetScreen = _eyeManager.WorldToScreen(sourcePos + new Vector2(1, 0));
var logicalPixelsPerMeter = (offsetScreen - sourceScreen).Length();
viewport.Screenshot(img => {
var scaleX = (float)img.Width / control.Size.X;
var scaleY = (float)img.Height / control.Size.Y;
var texturePixelsPerMeter = logicalPixelsPerMeter * scaleX;
var size = (int)(3.0f * texturePixelsPerMeter);
var localTargetLogical = targetScreen - control.GlobalPosition;
var centerX = localTargetLogical.X * scaleX;
var centerY = localTargetLogical.Y * scaleY;
var x = (int)(centerX - size / 2f);
var y = (int)(centerY - size / 2f);
x = Math.Clamp(x, 0, img.Width);
y = Math.Clamp(y, 0, img.Height);
var w = Math.Clamp(size, 1, img.Width - x);
var h = Math.Clamp(size, 1, img.Height - y);
var finalRect = new SixLabors.ImageSharp.Rectangle(x, y, w, h);
TakeScreenshot(img, finalRect);
});
}
private void TakeScreenshot<T>(Image<T> screenshot, SixLabors.ImageSharp.Rectangle cropRect) where T : unmanaged, IPixel<T>
{
try
{
ProcessCapturedImage(screenshot, cropRect);
}
catch (Exception ex)
{
_sawmill.Error($"Error processing screenshot: {ex.Message}");
}
finally
{
screenshot.Dispose();
}
}
private void ProcessCapturedImage<T>(Image<T> image, SixLabors.ImageSharp.Rectangle cropRect) where T : unmanaged, IPixel<T>
{
var processed = image.Clone(ctx =>
{
ctx.Crop(cropRect);
ctx.Resize(TargetPhotoWidth, TargetPhotoHeight);
});
var width = processed.Width;
var height = processed.Height;
byte[] imageData;
using (var memoryStream = new MemoryStream())
{
processed.SaveAsPng(memoryStream);
imageData = memoryStream.ToArray();
}
processed.Dispose();
SendPhotoToServer(imageData, width, height);
}
private void SendPhotoToServer(byte[] imageData, int width, int height)
{
if (!_netManager.IsConnected)
return;
var message = new PdaPhotoCaptureMessage
{
ImageData = imageData,
Width = width,
Height = height
};
_netManager.ClientSendMessage(message);
}
}

View file

@ -0,0 +1,40 @@
using Robust.Client.Graphics;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
public sealed class PhotoOverlaySystem : EntitySystem
{
[Dependency] private readonly IOverlayManager _overlayManager = default!;
private PhotoCaptureOverlay? _overlay;
public bool OverlayEnabled { get; private set; }
public EntityUid? ActiveCameraEntity { get; set; }
public override void Initialize()
{
base.Initialize();
_overlay = new PhotoCaptureOverlay();
}
public void SetOverlayEnabled(bool enabled, EntityUid? source = null)
{
OverlayEnabled = enabled;
ActiveCameraEntity = source;
if (enabled)
{
if (!_overlayManager.HasOverlay<PhotoCaptureOverlay>())
_overlayManager.AddOverlay(_overlay!);
}
else
{
_overlayManager.RemoveOverlay<PhotoCaptureOverlay>();
}
}
public override void Shutdown()
{
base.Shutdown();
_overlayManager.RemoveOverlay<PhotoCaptureOverlay>();
}
}

View file

@ -0,0 +1,65 @@
using Content.Client.UserInterface.Fragments;
using Content.Shared.CartridgeLoader;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Robust.Client.UserInterface;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
public sealed partial class PhotoUi : UIFragment
{
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
private PhotoUiFragment? _fragment;
public override Control GetUIFragmentRoot()
{
return _fragment!;
}
public override void Setup(BoundUserInterface userInterface, EntityUid? fragmentOwner)
{
IoCManager.InjectDependencies(this);
_fragment = new PhotoUiFragment();
if (fragmentOwner.HasValue)
_fragment.SetOwner(fragmentOwner.Value);
_fragment.OnCapturePhoto += () =>
{
if (fragmentOwner.HasValue)
_entitySystemManager.GetEntitySystem<PhotoCartridgeClientSystem>().CaptureAndSendPhoto(fragmentOwner.Value);
SendPhotoMessage(PhotoUiAction.CapturePhoto, userInterface);
};
_fragment.OnSendPhotoToMessenger += (photoId, recipientId, groupId) =>
SendPhotoMessage(PhotoUiAction.SendPhotoToMessenger, userInterface, photoId: photoId, recipientId: recipientId, groupId: groupId);
_fragment.OnRequestGallery += () =>
SendPhotoMessage(PhotoUiAction.RequestGallery, userInterface);
_fragment.OnDeletePhoto += photoId =>
SendPhotoMessage(PhotoUiAction.DeletePhoto, userInterface, photoId: photoId);
_fragment.OnToggleFlash += flashEnabled =>
SendPhotoMessage(PhotoUiAction.ToggleFlash, userInterface, flashEnabled: flashEnabled);
}
public override void UpdateState(BoundUserInterfaceState state)
{
if (state is not PhotoUiState photoState)
return;
_fragment?.UpdateState(photoState);
}
private void SendPhotoMessage(
PhotoUiAction action,
BoundUserInterface userInterface,
string? photoId = null,
string? recipientId = null,
string? groupId = null,
bool? flashEnabled = null)
{
var photoMessage = new PhotoUiMessageEvent(action, photoId, recipientId, groupId, flashEnabled);
var message = new CartridgeUiMessage(photoMessage);
userInterface.SendMessage(message);
}
}

View file

@ -0,0 +1,50 @@
<cartridges:PhotoUiFragment xmlns:cartridges="clr-namespace:Content.Client._Sunrise.CartridgeLoader.Cartridges"
xmlns:viewport="clr-namespace:Content.Client.Viewport"
xmlns="https://spacestation14.io" Margin="4"
Orientation="Vertical">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="0,0,0,4">
<Button Name="CameraTabBtn" Text="{Loc 'photo-cartridge-tab-camera'}" HorizontalExpand="True" ToggleMode="True" Pressed="True"/>
<Button Name="GalleryTabBtn" Text="{Loc 'photo-cartridge-tab-gallery'}" HorizontalExpand="True" ToggleMode="True"/>
</BoxContainer>
<Label Name="ErrorMessageLabel" Text="" StyleClasses="Danger" Visible="False"/>
<Label Name="PhotoCountLabel" Text="" Align="Right" StyleClasses="LabelSubText"/>
<BoxContainer Name="CameraView" Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True" Visible="True">
<PanelContainer Name="CameraPreviewContainer" HorizontalExpand="True" VerticalExpand="True" Margin="0,0,0,4">
<viewport:ScalingViewport Name="CameraPreview" HorizontalExpand="True" VerticalExpand="True"/>
</PanelContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="0,0,0,4">
<Label Text="{Loc 'photo-cartridge-zoom-label'}" Margin="0,0,8,0" VerticalAlignment="Center"/>
<Slider Name="ZoomSlider" HorizontalExpand="True" MinValue="0" MaxValue="1" Value="0" Margin="0,0,8,0"/>
<CheckBox Name="FlashCheckBox" Text="{Loc 'photo-cartridge-flash-label'}" Pressed="True"/>
</BoxContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Button Name="CaptureButton" Text="{Loc 'photo-cartridge-capture-button'}" HorizontalExpand="True" MinHeight="40"/>
<Button Name="ToggleOverlayButton" Text="{Loc 'photo-cartridge-overlay-button'}" MinWidth="80" MinHeight="40"
ToggleMode="True" Margin="4,0,0,0"/>
</BoxContainer>
</BoxContainer>
<BoxContainer Name="GalleryView" Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True" Visible="False">
<ScrollContainer Name="GalleryScroll" HorizontalExpand="True" VerticalExpand="True">
<GridContainer Name="GalleryContainer" Columns="2" HorizontalExpand="True"/>
</ScrollContainer>
</BoxContainer>
<BoxContainer Name="ImageViewOverlay" Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True" Visible="False" Margin="4">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Button Name="BackFromImageParams" Text="{Loc 'photo-cartridge-back-button'}" HorizontalAlignment="Left"/>
<Control HorizontalExpand="True"/>
<Button Name="DeleteImageBtn" Text="{Loc 'photo-cartridge-delete-button'}" StyleClasses="Caution" HorizontalAlignment="Right"/>
</BoxContainer>
<PanelContainer HorizontalExpand="True" VerticalExpand="True" Margin="0,4,0,0">
<TextureRect Name="FullImageRect" HorizontalExpand="True" VerticalExpand="True" Stretch="KeepAspectCentered"/>
</PanelContainer>
<Label Name="FullImageTimestamp" Align="Center" StyleClasses="LabelSubText"/>
</BoxContainer>
</cartridges:PhotoUiFragment>

View file

@ -0,0 +1,386 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Content.Client.Viewport;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Client.Player;
using Robust.Client.UserInterface;
using Robust.Shared.Timing;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
[GenerateTypedNameReferences]
public sealed partial class PhotoUiFragment : BoxContainer
{
public event Action? OnCapturePhoto;
public event Action<string>? OnDeletePhoto;
public event Action<string, string?, string?>? OnSendPhotoToMessenger;
public event Action? OnRequestGallery;
public event Action<bool>? OnToggleFlash;
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IEyeManager _eyeManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
private Dictionary<string, PhotoItemControl> _photoControls = new();
private string? _currentViewPhotoId;
private EntityUid? _ownerEntity;
private bool _serverCameraReady = true;
private readonly Robust.Shared.Graphics.Eye _previewEye = new();
public PhotoUiFragment()
{
IoCManager.InjectDependencies(this);
RobustXamlLoader.Load(this);
Orientation = LayoutOrientation.Vertical;
HorizontalExpand = true;
VerticalExpand = true;
CaptureButton.OnPressed += _ => OnCapturePhoto?.Invoke();
BackFromImageParams.OnPressed += _ => CloseFullPhoto();
DeleteImageBtn.OnPressed += _ =>
{
if (_currentViewPhotoId != null)
{
OnDeletePhoto?.Invoke(_currentViewPhotoId);
CloseFullPhoto();
}
};
FlashCheckBox.OnToggled += args => OnToggleFlash?.Invoke(args.Pressed);
ToggleOverlayButton.OnPressed += _ => ToggleCaptureAreaOverlay();
ToggleOverlayButton.Pressed = _entityManager.System<PhotoOverlaySystem>().OverlayEnabled;
CameraTabBtn.OnPressed += _ => SwitchTab(true);
GalleryTabBtn.OnPressed += _ => SwitchTab(false);
SwitchTab(true);
var system = _entityManager.System<PhotoCartridgeClientSystem>();
ZoomSlider.Value = (system.CaptureDistance - 2.0f) / 8.0f;
ZoomSlider.OnValueChanged += args =>
{
system.CaptureDistance = 2.0f + args.Value * 8.0f;
};
SetupCameraPreview();
}
public void SetOwner(EntityUid owner)
{
_ownerEntity = owner;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_entityManager.System<PhotoOverlaySystem>().SetOverlayEnabled(false);
}
}
private void SwitchTab(bool showCamera)
{
if (ImageViewOverlay.Visible)
ImageViewOverlay.Visible = false;
CameraTabBtn.Pressed = showCamera;
GalleryTabBtn.Pressed = !showCamera;
CameraView.Visible = showCamera;
GalleryView.Visible = !showCamera;
if (!showCamera)
{
OnRequestGallery?.Invoke();
}
}
private void CloseFullPhoto()
{
ImageViewOverlay.Visible = false;
GalleryView.Visible = true;
CameraTabBtn.Pressed = false;
GalleryTabBtn.Pressed = true;
}
private void OpenFullPhoto(string photoId, PhotoMetadata metadata, Texture? texture)
{
_currentViewPhotoId = photoId;
CameraView.Visible = false;
GalleryView.Visible = false;
ImageViewOverlay.Visible = true;
FullImageRect.Texture = texture;
FullImageTimestamp.Text = FormatTimestamp(metadata.Timestamp);
}
private string FormatTimestamp(TimeSpan timestamp)
{
var age = _gameTiming.CurTime - timestamp;
var realTimeOfCapture = DateTime.UtcNow - age;
var pdaTime = realTimeOfCapture + TimeSpan.FromHours(3);
return pdaTime.ToString("HH:mm:ss");
}
private void SetupCameraPreview()
{
if (CameraPreview != null)
{
CameraPreview.Eye = _previewEye;
CameraPreview.ViewportSize = new Robust.Shared.Maths.Vector2i(256, 256);
CameraPreview.RenderScaleMode = ScalingViewportRenderScaleMode.Fixed;
CameraPreview.FixedRenderScale = 1;
_previewEye.Zoom = _eyeManager.CurrentEye.Zoom * 0.4f;
}
}
/// <summary>
/// Переключает видимость оверлея области съемки
/// </summary>
private void ToggleCaptureAreaOverlay()
{
var system = _entityManager.System<PhotoOverlaySystem>();
system.SetOverlayEnabled(!system.OverlayEnabled, _ownerEntity);
ToggleOverlayButton.Pressed = system.OverlayEnabled;
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
var photoSystem = _entityManager.System<PhotoCartridgeClientSystem>();
CaptureButton.Disabled = !_serverCameraReady || !photoSystem.CameraReady;
if (!CameraView.Visible || CameraPreview == null)
return;
if (_playerManager.LocalPlayer?.ControlledEntity is { } player)
{
var source = _ownerEntity ?? player;
var xform = _entityManager.GetComponent<TransformComponent>(source);
var sourcePos = xform.MapPosition;
var targetPos = photoSystem.GetCameraPosition(source, photoSystem.CaptureDistance);
_previewEye.Position = sourcePos.Offset(targetPos - sourcePos.Position);
_previewEye.Rotation = _eyeManager.CurrentEye.Rotation;
_previewEye.Zoom = new Vector2(3f / 8f, 3f / 8f);
}
}
public void UpdateState(PhotoUiState state)
{
if (!string.IsNullOrEmpty(state.ErrorMessage))
{
ErrorMessageLabel.Text = state.ErrorMessage;
ErrorMessageLabel.Visible = true;
}
else
{
ErrorMessageLabel.Visible = false;
}
_serverCameraReady = state.CameraReady;
FlashCheckBox.Pressed = state.FlashEnabled;
PhotoCountLabel.Text = Loc.GetString("photo-cartridge-photos-count", ("count", state.Photos.Count), ("max", 50));
UpdateGallery(state.Photos);
}
private void UpdateGallery(Dictionary<string, PhotoMetadata> photos)
{
var toRemove = _photoControls.Keys.Except(photos.Keys).ToList();
foreach (var photoId in toRemove)
{
if (_photoControls.TryGetValue(photoId, out var control))
{
control.Dispose();
_photoControls.Remove(photoId);
}
}
var sortedPhotos = photos.OrderByDescending(p => p.Value.Timestamp);
foreach (var (photoId, metadata) in sortedPhotos)
{
if (!_photoControls.ContainsKey(photoId))
{
var control = new PhotoItemControl(photoId, metadata, _netTexturesManager, _resourceCache, _gameTiming);
control.OnPressed += _ => OpenFullPhoto(photoId, metadata, control.GetTexture());
GalleryContainer.AddChild(control);
_photoControls[photoId] = control;
}
else
{
_photoControls[photoId].UpdateMetadata(metadata);
_photoControls[photoId].SetPositionInParent(0);
}
}
int index = 0;
foreach (var (photoId, _) in sortedPhotos)
{
if (_photoControls.TryGetValue(photoId, out var control))
{
control.SetPositionInParent(index++);
}
}
}
}
/// <summary>
/// Элемент управления для отображения одной фотографии в галерее
/// </summary>
public sealed class PhotoItemControl : ContainerButton
{
private readonly string _photoId;
private PhotoMetadata _metadata;
private readonly NetTexturesManager _netTexturesManager;
private readonly IResourceCache _resourceCache;
private readonly IGameTiming _gameTiming;
private TextureRect _previewRect;
private Label _timestampLabel;
public PhotoItemControl(string photoId, PhotoMetadata metadata, NetTexturesManager netTexturesManager, IResourceCache resourceCache, IGameTiming gameTiming)
{
_photoId = photoId;
_metadata = metadata;
_netTexturesManager = netTexturesManager;
_resourceCache = resourceCache;
_gameTiming = gameTiming;
var vBox = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true,
VerticalExpand = true,
Margin = new Thickness(2)
};
AddChild(vBox);
_previewRect = new TextureRect
{
MinHeight = 84,
HorizontalExpand = true,
VerticalExpand = true,
Stretch = TextureRect.StretchMode.KeepAspectCentered,
Margin = new Thickness(0, 0, 0, 2)
};
var borderPanel = new PanelContainer
{
HorizontalExpand = true,
VerticalExpand = true,
PanelOverride = new StyleBoxFlat
{
BackgroundColor = Color.Transparent,
BorderColor = Color.Transparent,
BorderThickness = new Thickness(2)
}
};
vBox.AddChild(borderPanel);
borderPanel.AddChild(_previewRect);
OnMouseEntered += _ =>
{
if (borderPanel.PanelOverride is StyleBoxFlat style)
{
style.BorderColor = Color.White.WithAlpha(0.6f);
}
};
OnMouseExited += _ =>
{
if (borderPanel.PanelOverride is StyleBoxFlat style)
{
style.BorderColor = Color.Transparent;
}
};
_timestampLabel = new Label
{
Text = FormatTimestamp(metadata.Timestamp),
Align = Label.AlignMode.Center,
StyleClasses = { "LabelSubText" },
ClipText = true
};
vBox.AddChild(_timestampLabel);
LoadImage();
}
public Texture? GetTexture()
{
return _previewRect.Texture;
}
public void UpdateMetadata(PhotoMetadata metadata)
{
_metadata = metadata;
_timestampLabel.Text = FormatTimestamp(metadata.Timestamp);
}
private void LoadImage()
{
var isAvailable = _netTexturesManager.EnsureResource(_metadata.ImagePath);
if (isAvailable)
{
LoadImageTexture(_metadata.ImagePath);
}
else
{
_netTexturesManager.ResourceLoaded += OnResourceLoaded;
}
}
private void OnResourceLoaded(string resourcePath)
{
if (resourcePath == _metadata.ImagePath)
{
LoadImageTexture(resourcePath);
_netTexturesManager.ResourceLoaded -= OnResourceLoaded;
}
}
private void LoadImageTexture(string imagePath)
{
try
{
var uploadedPath = _netTexturesManager.GetUploadedPath(imagePath);
if (_resourceCache.TryGetResource<TextureResource>(uploadedPath, out var textureResource))
{
_previewRect.Texture = textureResource.Texture;
}
}
catch (Exception)
{
}
}
private string FormatTimestamp(TimeSpan timestamp)
{
var age = _gameTiming.CurTime - timestamp;
var realTimeOfCapture = DateTime.UtcNow - age;
var pdaTime = realTimeOfCapture + TimeSpan.FromHours(3);
return pdaTime.ToString("HH:mm:ss");
}
}

View file

@ -20,7 +20,7 @@ public sealed partial class MessengerCartridgeSystem
return;
}
var station = _stationSystem.GetOwningStation(pdaUid);
var station = GetBestStation(pdaUid);
if (station == null)
{
component.ServerAddress = null;
@ -175,7 +175,7 @@ public sealed partial class MessengerCartridgeSystem
component.LoaderUid = loaderUid;
var station = _stationSystem.GetOwningStation(pdaUid);
var station = GetBestStation(pdaUid);
if (station == null)
{
Sawmill.Warning($"No station found for PDA: {ToPrettyString(pdaUid)}");

View file

@ -24,7 +24,7 @@ public sealed partial class MessengerCartridgeSystem
{
case MessengerUiAction.SendMessage:
if (message.Content != null)
SendMessage(uid, component, loaderUid, deviceNetwork, message.RecipientId, message.GroupId, message.Content);
SendMessage(uid, component, loaderUid, deviceNetwork, message.RecipientId, message.GroupId, message.Content, message.ImagePath);
break;
case MessengerUiAction.CreateGroup:
if (message.GroupName != null)
@ -72,10 +72,29 @@ public sealed partial class MessengerCartridgeSystem
if (message.ChatId != null)
TogglePin(uid, component, message.ChatId);
break;
case MessengerUiAction.RequestPhotos:
RequestPhotos(uid, component, loaderUid);
break;
}
}
private void SendMessage(EntityUid uid, MessengerCartridgeComponent component, EntityUid loaderUid, DeviceNetworkComponent deviceNetwork, string? recipientId, string? groupId, string content)
private void RequestPhotos(EntityUid uid, MessengerCartridgeComponent component, EntityUid loaderUid)
{
var photoGallery = new Dictionary<string, PhotoMetadata>();
foreach (var cartridgeUid in _cartridgeLoader.GetInstalled(loaderUid))
{
if (TryComp<PhotoCartridgeComponent>(cartridgeUid, out var photoComp))
{
photoGallery = photoComp.PhotoGallery;
break;
}
}
UpdateUiState(uid, loaderUid, component, photoGallery);
}
private void SendMessage(EntityUid uid, MessengerCartridgeComponent component, EntityUid loaderUid, DeviceNetworkComponent deviceNetwork, string? recipientId, string? groupId, string content, string? imagePath = null)
{
if (component.ServerAddress == null || !component.IsRegistered)
return;
@ -98,15 +117,22 @@ public sealed partial class MessengerCartridgeSystem
return;
}
var payload = new NetworkPayload
{
[DeviceNetworkConstants.Command] = MessengerCommands.CmdSendMessage,
[MessengerCommands.CmdSendMessage] = new NetworkPayload
var messagePayload = new NetworkPayload
{
["content"] = content,
["recipient_id"] = recipientId ?? string.Empty,
["group_id"] = groupId ?? string.Empty
};
if (!string.IsNullOrEmpty(imagePath))
{
messagePayload["image_path"] = imagePath;
}
var payload = new NetworkPayload
{
[DeviceNetworkConstants.Command] = MessengerCommands.CmdSendMessage,
[MessengerCommands.CmdSendMessage] = messagePayload
};
_deviceNetwork.QueuePacket(loaderUid, component.ServerAddress, payload, frequency: messengerFreq, network: pdaDevice.DeviceNetId);

View file

@ -282,6 +282,7 @@ public sealed partial class MessengerCartridgeSystem
messageData.TryGetValue("is_read", out object? isReadObj);
messageData.TryGetValue("message_id", out object? messageIdObj);
messageData.TryGetValue("sender_job_icon_id", out object? senderJobIconIdObj);
messageData.TryGetValue("image_path", out object? imagePathObj);
var groupId = groupIdObj?.ToString();
var recipientId = recipientIdObj?.ToString();
@ -304,8 +305,12 @@ public sealed partial class MessengerCartridgeSystem
senderJobIconId = new ProtoId<JobIconPrototype>(senderJobIconIdObj.ToString()!);
}
var imagePath = imagePathObj?.ToString();
if (string.IsNullOrWhiteSpace(imagePath))
imagePath = null;
var timestamp = TimeSpan.FromSeconds(timestampSeconds);
messages.Add(new MessengerMessage(senderId, senderName, content, timestamp, groupId, recipientId, isRead, messageId, senderJobIconId));
messages.Add(new MessengerMessage(senderId, senderName, content, timestamp, groupId, recipientId, isRead, messageId, senderJobIconId, imagePath));
}
if (!string.IsNullOrEmpty(chatId) && messages.Count >= 0)
@ -419,6 +424,7 @@ public sealed partial class MessengerCartridgeSystem
packet.Data.TryGetValue("is_read", out object? isReadObj);
packet.Data.TryGetValue("message_id", out object? messageIdObj);
packet.Data.TryGetValue("sender_job_icon_id", out object? senderJobIconIdObj);
packet.Data.TryGetValue("image_path", out object? imagePathObj);
var groupId = groupIdObj?.ToString();
var recipientId = recipientIdObj?.ToString();
@ -441,8 +447,12 @@ public sealed partial class MessengerCartridgeSystem
senderJobIconId = new ProtoId<JobIconPrototype>(senderJobIconIdObj.ToString()!);
}
var imagePath = imagePathObj?.ToString();
if (string.IsNullOrWhiteSpace(imagePath))
imagePath = null;
var timestamp = TimeSpan.FromSeconds(timestampSeconds);
var message = new MessengerMessage(senderId, senderName, content, timestamp, groupId, recipientId, isRead, messageId, senderJobIconId);
var message = new MessengerMessage(senderId, senderName, content, timestamp, groupId, recipientId, isRead, messageId, senderJobIconId, imagePath);
string chatId;
if (!string.IsNullOrEmpty(groupId))
@ -572,7 +582,6 @@ public sealed partial class MessengerCartridgeSystem
{
component.ActiveInvites.Add(invite);
// Воспроизводим рингтон при получении нового инвайта
if (TryComp<RingerComponent>(loaderUid, out var ringer))
{
_ringer.RingerPlayRingtone(loaderUid);

View file

@ -8,7 +8,7 @@ namespace Content.Server._Sunrise.CartridgeLoader.Cartridges;
/// </summary>
public sealed partial class MessengerCartridgeSystem
{
private void UpdateUiState(EntityUid uid, EntityUid loaderUid, MessengerCartridgeComponent? component)
private void UpdateUiState(EntityUid uid, EntityUid loaderUid, MessengerCartridgeComponent? component, Dictionary<string, PhotoMetadata>? photoGallery = null)
{
if (!Resolve(uid, ref component))
return;
@ -57,7 +57,8 @@ public sealed partial class MessengerCartridgeSystem
component.MutedGroupChats,
unreadCounts,
component.ActiveInvites,
component.PinnedChats
component.PinnedChats,
photoGallery
);
_cartridgeLoader.UpdateCartridgeUiState(loaderUid, state);

View file

@ -1,3 +1,4 @@
using System.Linq;
using Content.Server.DeviceNetwork.Systems;
using Content.Server.CartridgeLoader;
using Content.Server.PDA.Ringer;
@ -129,4 +130,25 @@ public sealed partial class MessengerCartridgeSystem : EntitySystem
_deviceNetwork.SetTransmitFrequency(loaderUid, originalFrequency.Value, deviceNetwork);
}
}
/// <summary>
/// Пытается найти станцию для КПК. Если КПК не на станции, ищет любую станцию на той же карте.
/// </summary>
private EntityUid? GetBestStation(EntityUid pdaUid)
{
var station = _stationSystem.GetOwningStation(pdaUid);
if (station != null)
return station;
var xform = Transform(pdaUid);
var mapId = xform.MapID;
foreach (var s in _stationSystem.GetStations())
{
if (Transform(s).MapID == mapId)
return s;
}
return _stationSystem.GetStations().FirstOrDefault();
}
}

View file

@ -0,0 +1,324 @@
using System.Linq;
using Content.Server.CartridgeLoader;
using Content.Server.DeviceNetwork.Systems;
using Content.Server._Sunrise;
using Content.Server._Sunrise.Messenger;
using Content.Server.Station.Systems;
using Content.Shared.CartridgeLoader;
using Content.Shared.DeviceNetwork;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.Inventory;
using Content.Shared.PDA;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Shared._Sunrise.Messenger;
using Robust.Server.Player;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.Audio.Systems;
namespace Content.Server._Sunrise.CartridgeLoader.Cartridges;
public sealed class PhotoCartridgeSystem : EntitySystem
{
[Dependency] private readonly CartridgeLoaderSystem _cartridgeLoader = default!;
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
[Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly MessengerServerSystem _messengerServer = default!;
[Dependency] private readonly StationSystem _stationSystem = default!;
[Dependency] private readonly SingletonDeviceNetServerSystem _singletonServer = default!;
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
private ISawmill _sawmill = default!;
private const int MaxPhotosPerUser = 50;
private const int MaxPhotoSizeBytes = 2 * 1024 * 1024;
private const double MinTimeBetweenCapturesSeconds = 1.0;
private const int MaxPhotoWidth = 512;
private const int MaxPhotoHeight = 512;
private readonly Dictionary<ICommonSession, TimeSpan> _lastCaptureTimes = new();
public override void Initialize()
{
base.Initialize();
_sawmill = _logManager.GetSawmill("photo.cartridge");
SubscribeLocalEvent<PhotoCartridgeComponent, CartridgeMessageEvent>(OnUiMessage);
SubscribeLocalEvent<PhotoCartridgeComponent, CartridgeUiReadyEvent>(OnUiReady);
_netManager.RegisterNetMessage<PdaPhotoCaptureMessage>(OnPhotoCaptureMessage, accept: NetMessageAccept.Server);
}
private void OnUiMessage(EntityUid uid, PhotoCartridgeComponent component, CartridgeMessageEvent args)
{
if (args is not PhotoUiMessageEvent photoMessage)
return;
var loaderUid = GetEntity(args.LoaderUid);
if (loaderUid == EntityUid.Invalid)
return;
switch (photoMessage.Action)
{
case PhotoUiAction.CapturePhoto:
HandleCapturePhoto(uid, component, loaderUid);
break;
case PhotoUiAction.SendPhotoToMessenger:
HandleSendPhotoToMessenger(uid, component, loaderUid, photoMessage.PhotoId, photoMessage.RecipientId, photoMessage.GroupId);
break;
case PhotoUiAction.RequestGallery:
UpdateUiState(uid, loaderUid, component);
break;
case PhotoUiAction.DeletePhoto:
HandleDeletePhoto(uid, component, loaderUid, photoMessage.PhotoId);
break;
case PhotoUiAction.ToggleFlash:
if (photoMessage.FlashEnabled.HasValue)
{
component.FlashEnabled = photoMessage.FlashEnabled.Value;
UpdateUiState(uid, loaderUid, component);
}
break;
}
}
private void HandleDeletePhoto(EntityUid uid, PhotoCartridgeComponent component, EntityUid loaderUid, string? photoId)
{
if (string.IsNullOrEmpty(photoId) || !component.PhotoGallery.TryGetValue(photoId, out var metadata))
return;
component.PhotoGallery.Remove(photoId);
UpdateUiState(uid, loaderUid, component);
}
private void OnUiReady(EntityUid uid, PhotoCartridgeComponent component, CartridgeUiReadyEvent args)
{
UpdateUiState(uid, args.Loader, component);
}
private void HandleCapturePhoto(EntityUid uid, PhotoCartridgeComponent component, EntityUid loaderUid)
{
if (component.PhotoGallery.Count >= MaxPhotosPerUser)
{
UpdateUiState(uid, loaderUid, component, errorMessage: Loc.GetString("photo-cartridge-limit-reached"));
return;
}
if (component.FlashEnabled)
{
Spawn(component.FlashEffect, _transform.GetMapCoordinates(loaderUid));
}
_audio.PlayPvs(component.Sound, loaderUid);
UpdateUiState(uid, loaderUid, component);
}
/// <summary>
/// Обработчик сетевого сообщения с захваченным изображением от клиента
/// </summary>
private void OnPhotoCaptureMessage(PdaPhotoCaptureMessage msg)
{
if (!_playerManager.TryGetSessionByChannel(msg.MsgChannel, out var session))
return;
if (_lastCaptureTimes.TryGetValue(session, out var lastCapture))
{
var timeSinceLastCapture = _gameTiming.CurTime - lastCapture;
if (timeSinceLastCapture.TotalSeconds < MinTimeBetweenCapturesSeconds)
{
_sawmill.Debug($"Photo capture rejected: cooldown active ({timeSinceLastCapture.TotalSeconds:F2}s < {MinTimeBetweenCapturesSeconds}s)");
return;
}
}
if (msg.ImageData.Length > MaxPhotoSizeBytes)
{
_sawmill.Warning($"Photo capture rejected: image too large ({msg.ImageData.Length} bytes > {MaxPhotoSizeBytes} bytes)");
return;
}
if (msg.Width <= 0 || msg.Height <= 0 || msg.Width > MaxPhotoWidth || msg.Height > MaxPhotoHeight)
{
_sawmill.Warning($"Photo capture rejected: invalid dimensions ({msg.Width}x{msg.Height})");
return;
}
var pdaUid = FindPlayerPda(session);
if (pdaUid == null)
{
_sawmill.Warning($"Photo capture rejected: no PDA found for player {session.Name}");
return;
}
if (!_cartridgeLoader.TryGetProgram<PhotoCartridgeComponent>(pdaUid.Value, out var cartridgeUid, out var photoComponent))
{
_sawmill.Warning($"Photo capture rejected: photo cartridge not found in PDA {ToPrettyString(pdaUid.Value)}");
return;
}
if (photoComponent.PhotoGallery.Count >= MaxPhotosPerUser)
{
UpdateUiState(cartridgeUid.Value, pdaUid.Value, photoComponent, errorMessage: Loc.GetString("photo-cartridge-limit-reached"));
return;
}
var photoId = Guid.NewGuid().ToString();
var imagePath = $"/NetTextures/Messenger/{photoId}.png";
_netTexturesManager.RegisterDynamicResource(imagePath, msg.ImageData);
var timestamp = _gameTiming.CurTime;
var metadata = new PhotoMetadata(photoId, imagePath, timestamp);
photoComponent.PhotoGallery[photoId] = metadata;
_lastCaptureTimes[session] = timestamp;
_sawmill.Info($"Photo captured from {session.Name}: {photoId}, path: {imagePath}, size: {msg.Width}x{msg.Height}, {msg.ImageData.Length} bytes");
UpdateUiState(cartridgeUid.Value, pdaUid.Value, photoComponent);
}
/// <summary>
/// Находит КПК игрока по его сессии
/// </summary>
private EntityUid? FindPlayerPda(ICommonSession session)
{
if (session.AttachedEntity == null)
return null;
var playerEntity = session.AttachedEntity.Value;
if (_inventory.TryGetSlotEntity(playerEntity, "idcard", out var idCardEntity) &&
TryComp<PdaComponent>(idCardEntity, out _))
{
return idCardEntity;
}
if (_inventory.TryGetSlotEntity(playerEntity, "belt", out var beltEntity) &&
TryComp<PdaComponent>(beltEntity, out _))
{
return beltEntity;
}
var pdaQuery = EntityQueryEnumerator<PdaComponent>();
while (pdaQuery.MoveNext(out var uid, out var pda))
{
if (pda.PdaOwner == playerEntity)
{
return uid;
}
}
return null;
}
private void HandleSendPhotoToMessenger(EntityUid uid, PhotoCartridgeComponent component, EntityUid loaderUid, string? photoId, string? recipientId, string? groupId)
{
if (string.IsNullOrWhiteSpace(photoId))
return;
if (!component.PhotoGallery.TryGetValue(photoId, out var photoMetadata))
{
UpdateUiState(uid, loaderUid, component, errorMessage: Loc.GetString("photo-cartridge-photo-not-found"));
return;
}
var messengerServer = FindMessengerServer(loaderUid);
if (messengerServer == null)
{
UpdateUiState(uid, loaderUid, component, errorMessage: Loc.GetString("photo-cartridge-messenger-unavailable"));
return;
}
if (!TryComp<DeviceNetworkComponent>(loaderUid, out var pdaDevice))
return;
var userId = pdaDevice.Address;
if (string.IsNullOrEmpty(userId))
return;
var timestamp = _gameTiming.CurTime;
var content = Loc.GetString("photo-cartridge-photo-text");
if (!string.IsNullOrWhiteSpace(groupId))
{
_messengerServer.SendGroupMessageWithImage(messengerServer.Value, userId, groupId, content, photoMetadata.ImagePath, timestamp);
}
else if (!string.IsNullOrWhiteSpace(recipientId))
{
_messengerServer.SendPersonalMessageWithImage(messengerServer.Value, userId, recipientId, content, photoMetadata.ImagePath, timestamp);
}
else
{
UpdateUiState(uid, loaderUid, component, errorMessage: Loc.GetString("photo-cartridge-recipient-not-specified"));
return;
}
UpdateUiState(uid, loaderUid, component);
}
private EntityUid? FindMessengerServer(EntityUid pdaUid)
{
var station = _stationSystem.GetOwningStation(pdaUid);
if (station == null)
{
var xform = Transform(pdaUid);
var mapId = xform.MapID;
foreach (var s in _stationSystem.GetStations())
{
if (Transform(s).MapID == mapId)
{
station = s;
break;
}
}
}
if (station == null)
station = _stationSystem.GetStations().FirstOrDefault();
if (station == null)
return null;
if (!_singletonServer.TryGetActiveServerAddress<MessengerServerComponent>(station.Value, out var serverAddress))
return null;
var serverQuery = EntityQueryEnumerator<MessengerServerComponent, DeviceNetworkComponent>();
while (serverQuery.MoveNext(out var uid, out _, out var deviceNetwork))
{
if (deviceNetwork.Address == serverAddress && _singletonServer.IsActiveServer(uid))
{
return uid;
}
}
return null;
}
private void UpdateUiState(EntityUid uid, EntityUid loaderUid, PhotoCartridgeComponent component, string? errorMessage = null)
{
var state = new PhotoUiState(
photos: component.PhotoGallery,
cameraReady: component.PhotoGallery.Count < MaxPhotosPerUser,
flashEnabled: component.FlashEnabled,
errorMessage: errorMessage
);
_cartridgeLoader.UpdateCartridgeUiState(loaderUid, state);
}
private EntityUid GetEntity(NetEntity netEntity)
{
return EntityManager.GetEntity(netEntity);
}
}

View file

@ -101,14 +101,12 @@ public sealed partial class MessengerServerSystem
if (group.Members.Contains(userId))
return;
// Проверяем, нет ли уже активного инвайта
if (component.ActiveInvites.TryGetValue(userId, out var existingInvites))
{
if (existingInvites.Any(inv => inv.GroupId == groupId))
return;
}
// Создаем инвайт
var invite = new MessengerGroupInvite(
groupId,
group.Name,
@ -144,12 +142,10 @@ public sealed partial class MessengerServerSystem
_deviceNetwork.QueuePacket(uid, userId, invitePayload);
}
// Отправляем обновленный список инвайтов
SendInvitesList(uid, component, userId, serverDevice, pdaFrequency);
return;
}
// Для автоматических групп сохраняем старую логику (если разрешено)
if (group.AutoGroupPrototypeId != null)
{
if (_prototypeManager.TryIndex<MessengerAutoGroupPrototype>(group.AutoGroupPrototypeId, out var autoGroupProto))
@ -207,7 +203,8 @@ public sealed partial class MessengerServerSystem
["group_id"] = groupId,
["recipient_id"] = string.Empty,
["is_read"] = false,
["message_id"] = systemMessage.MessageId
["message_id"] = systemMessage.MessageId,
["image_path"] = systemMessage.ImagePath ?? string.Empty
};
foreach (var memberId in group.Members)
@ -273,7 +270,9 @@ public sealed partial class MessengerServerSystem
["group_id"] = msg.GroupId ?? string.Empty,
["recipient_id"] = msg.RecipientId ?? string.Empty,
["is_read"] = msg.IsRead,
["message_id"] = msg.MessageId
["message_id"] = msg.MessageId,
["sender_job_icon_id"] = msg.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = msg.ImagePath ?? string.Empty
});
}
@ -377,7 +376,9 @@ public sealed partial class MessengerServerSystem
["timestamp"] = timestamp.TotalSeconds,
["group_id"] = groupId,
["recipient_id"] = string.Empty,
["is_read"] = false
["is_read"] = false,
["message_id"] = systemMessage.MessageId,
["image_path"] = systemMessage.ImagePath ?? string.Empty
};
foreach (var memberId in group.Members)
@ -463,7 +464,8 @@ public sealed partial class MessengerServerSystem
["group_id"] = groupId,
["recipient_id"] = string.Empty,
["is_read"] = false,
["message_id"] = systemMessage.MessageId
["message_id"] = systemMessage.MessageId,
["image_path"] = systemMessage.ImagePath ?? string.Empty
};
foreach (var memberId in group.Members)
@ -529,7 +531,9 @@ public sealed partial class MessengerServerSystem
["group_id"] = msg.GroupId ?? string.Empty,
["recipient_id"] = msg.RecipientId ?? string.Empty,
["is_read"] = msg.IsRead,
["message_id"] = msg.MessageId
["message_id"] = msg.MessageId,
["sender_job_icon_id"] = msg.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = msg.ImagePath ?? string.Empty
});
}
@ -722,7 +726,8 @@ public sealed partial class MessengerServerSystem
["group_id"] = groupId,
["recipient_id"] = string.Empty,
["is_read"] = false,
["message_id"] = systemMessage.MessageId
["message_id"] = systemMessage.MessageId,
["image_path"] = systemMessage.ImagePath ?? string.Empty
};
foreach (var memberId in group.Members)

View file

@ -16,7 +16,16 @@ public sealed partial class MessengerServerSystem
if (!args.Data.TryGetValue(MessengerCommands.CmdSendMessage, out NetworkPayload? messageData))
return;
if (!messageData.TryGetValue("content", out string? content) || string.IsNullOrWhiteSpace(content))
if (!messageData.TryGetValue("content", out string? content))
return;
string? imagePath = null;
if (messageData.TryGetValue("image_path", out string? imgPath) && !string.IsNullOrWhiteSpace(imgPath))
{
imagePath = imgPath;
}
if (string.IsNullOrWhiteSpace(content) && string.IsNullOrWhiteSpace(imagePath))
return;
if (!component.Users.TryGetValue(args.SenderAddress, out var sender))
@ -28,21 +37,21 @@ public sealed partial class MessengerServerSystem
if (messageData.TryGetValue("group_id", out string? groupId) && !string.IsNullOrWhiteSpace(groupId))
{
SendGroupMessage(uid, component, sender, groupId, content, timestamp);
SendGroupMessage(uid, component, sender, groupId, content, timestamp, imagePath);
}
else if (messageData.TryGetValue("recipient_id", out string? recipientId) && !string.IsNullOrWhiteSpace(recipientId))
{
SendPersonalMessage(uid, component, sender, recipientId, content, timestamp);
SendPersonalMessage(uid, component, sender, recipientId, content, timestamp, imagePath);
}
}
private void SendPersonalMessage(EntityUid uid, MessengerServerComponent component, MessengerUser sender, string recipientId, string content, TimeSpan timestamp)
private void SendPersonalMessage(EntityUid uid, MessengerServerComponent component, MessengerUser sender, string recipientId, string content, TimeSpan timestamp, string? imagePath = null)
{
if (!component.Users.ContainsKey(recipientId))
return;
var messageId = GetNextMessageId(uid, component);
var message = new MessengerMessage(sender.UserId, sender.Name, content, timestamp, null, recipientId, isRead: false, messageId, sender.JobIconId);
var message = new MessengerMessage(sender.UserId, sender.Name, content, timestamp, null, recipientId, isRead: false, messageId, sender.JobIconId, imagePath);
var chatId = GetPersonalChatId(sender.UserId, recipientId);
if (!component.MessageHistory.TryGetValue(chatId, out var history))
@ -91,7 +100,8 @@ public sealed partial class MessengerServerSystem
["recipient_id"] = message.RecipientId ?? string.Empty,
["is_read"] = message.IsRead,
["message_id"] = message.MessageId,
["sender_job_icon_id"] = message.SenderJobIconId?.Id ?? string.Empty
["sender_job_icon_id"] = message.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = message.ImagePath ?? string.Empty
};
if (pdaFrequency.HasValue)
@ -121,7 +131,9 @@ public sealed partial class MessengerServerSystem
["group_id"] = message.GroupId ?? string.Empty,
["recipient_id"] = message.RecipientId ?? string.Empty,
["is_read"] = message.IsRead,
["message_id"] = message.MessageId
["message_id"] = message.MessageId,
["sender_job_icon_id"] = message.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = message.ImagePath ?? string.Empty
}
},
["chat_id"] = chatId
@ -130,7 +142,7 @@ public sealed partial class MessengerServerSystem
}
}
private void SendGroupMessage(EntityUid uid, MessengerServerComponent component, MessengerUser sender, string groupId, string content, TimeSpan timestamp)
private void SendGroupMessage(EntityUid uid, MessengerServerComponent component, MessengerUser sender, string groupId, string content, TimeSpan timestamp, string? imagePath = null)
{
if (!component.Groups.TryGetValue(groupId, out var group))
return;
@ -139,7 +151,7 @@ public sealed partial class MessengerServerSystem
return;
var messageId = GetNextMessageId(uid, component);
var message = new MessengerMessage(sender.UserId, sender.Name, content, timestamp, groupId, null, false, messageId, sender.JobIconId);
var message = new MessengerMessage(sender.UserId, sender.Name, content, timestamp, groupId, null, false, messageId, sender.JobIconId, imagePath);
if (!component.MessageHistory.TryGetValue(groupId, out var history))
{
@ -189,7 +201,8 @@ public sealed partial class MessengerServerSystem
["recipient_id"] = message.RecipientId ?? string.Empty,
["is_read"] = message.IsRead,
["message_id"] = message.MessageId,
["sender_job_icon_id"] = message.SenderJobIconId?.Id ?? string.Empty
["sender_job_icon_id"] = message.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = message.ImagePath ?? string.Empty
};
foreach (var memberId in group.Members)
@ -205,6 +218,34 @@ public sealed partial class MessengerServerSystem
}
}
/// <summary>
/// Отправляет личное сообщение с изображением (используется PhotoCartridgeSystem)
/// </summary>
public void SendPersonalMessageWithImage(EntityUid uid, string senderUserId, string recipientId, string content, string imagePath, TimeSpan timestamp)
{
if (!TryComp<MessengerServerComponent>(uid, out var component))
return;
if (!component.Users.TryGetValue(senderUserId, out var sender))
return;
SendPersonalMessage(uid, component, sender, recipientId, content, timestamp, imagePath);
}
/// <summary>
/// Отправляет групповое сообщение с изображением (используется PhotoCartridgeSystem)
/// </summary>
public void SendGroupMessageWithImage(EntityUid uid, string senderUserId, string groupId, string content, string imagePath, TimeSpan timestamp)
{
if (!TryComp<MessengerServerComponent>(uid, out var component))
return;
if (!component.Users.TryGetValue(senderUserId, out var sender))
return;
SendGroupMessage(uid, component, sender, groupId, content, timestamp, imagePath);
}
/// <summary>
/// Обрабатывает удаление сообщения пользователем
/// </summary>

View file

@ -210,7 +210,8 @@ public sealed partial class MessengerServerSystem
["recipient_id"] = msg.RecipientId ?? string.Empty,
["is_read"] = msg.IsRead,
["message_id"] = msg.MessageId,
["sender_job_icon_id"] = msg.SenderJobIconId?.Id ?? string.Empty
["sender_job_icon_id"] = msg.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = msg.ImagePath ?? string.Empty
});
}
}
@ -253,7 +254,8 @@ public sealed partial class MessengerServerSystem
["recipient_id"] = msg.RecipientId ?? string.Empty,
["is_read"] = msg.IsRead,
["message_id"] = msg.MessageId,
["sender_job_icon_id"] = msg.SenderJobIconId?.Id ?? string.Empty
["sender_job_icon_id"] = msg.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = msg.ImagePath ?? string.Empty
});
}
@ -302,7 +304,8 @@ public sealed partial class MessengerServerSystem
["recipient_id"] = message.RecipientId ?? string.Empty,
["is_read"] = message.IsRead,
["message_id"] = message.MessageId,
["sender_job_icon_id"] = message.SenderJobIconId?.Id ?? string.Empty
["sender_job_icon_id"] = message.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = message.ImagePath ?? string.Empty
});
}

View file

@ -59,6 +59,15 @@ public sealed partial class MessengerServerSystem
}
var station = _stationSystem.GetOwningStation(args.Mob);
if (station == null)
{
var mobXform = Transform(args.Mob);
station = _stationSystem.GetStations().FirstOrDefault(s => Transform(s).MapID == mobXform.MapID);
}
if (station == null)
station = _stationSystem.GetStations().FirstOrDefault();
if (station == null)
{
Sawmill.Warning($"No station found for player: {ToPrettyString(args.Mob)}");
@ -184,7 +193,6 @@ public sealed partial class MessengerServerSystem
_deviceNetwork.QueuePacket(uid, userId, response, frequency: pdaFrequency, network: serverDevice.DeviceNetId);
}
// Отправляем список пользователей новому пользователю
var usersPayload = new NetworkPayload
{
[DeviceNetworkConstants.Command] = MessengerCommands.CmdUsersList,
@ -203,7 +211,6 @@ public sealed partial class MessengerServerSystem
_deviceNetwork.QueuePacket(uid, userId, usersPayload, frequency: pdaFrequency, network: serverDevice.DeviceNetId);
}
// Отправляем список групп новому пользователю
var groupsList = component.Groups.Values.ToList();
var groupsData = new List<Dictionary<string, object>>();
@ -270,7 +277,6 @@ public sealed partial class MessengerServerSystem
_deviceNetwork.QueuePacket(uid, userId, groupsPayload, frequency: pdaFrequency, network: serverDevice.DeviceNetId);
}
// Регистрируем картридж мессенджера как фоновую программу, если он еще не зарегистрирован
if (_cartridgeLoader.TryGetProgram<MessengerCartridgeComponent>(pdaUid, out var cartridgeUid, out _))
{
if (TryComp<CartridgeLoaderComponent>(pdaUid, out var loader) &&
@ -503,7 +509,8 @@ public sealed partial class MessengerServerSystem
["group_id"] = autoGroupProto.GroupId,
["recipient_id"] = string.Empty,
["is_read"] = false,
["message_id"] = systemMessage.MessageId
["message_id"] = systemMessage.MessageId,
["image_path"] = systemMessage.ImagePath ?? string.Empty
};
foreach (var memberId in group.Members)
@ -551,7 +558,9 @@ public sealed partial class MessengerServerSystem
["group_id"] = msg.GroupId ?? string.Empty,
["recipient_id"] = msg.RecipientId ?? string.Empty,
["is_read"] = msg.IsRead,
["message_id"] = msg.MessageId
["message_id"] = msg.MessageId,
["sender_job_icon_id"] = msg.SenderJobIconId?.Id ?? string.Empty,
["image_path"] = msg.ImagePath ?? string.Empty
});
}
@ -642,7 +651,6 @@ public sealed partial class MessengerServerSystem
user.DepartmentId = departmentId;
user.JobIconId = jobIconId;
// Отправляем обновленный список пользователей всем клиентам
if (!TryComp<DeviceNetworkComponent>(uid, out var serverDevice))
return;

View file

@ -0,0 +1,25 @@
using Content.Shared.GameTicking;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
namespace Content.Server._Sunrise;
/// <summary>
/// A small system that handles event-based cleanup for the NetTexturesManager.
/// Since NetTexturesManager is a standalone manager, it cannot safely subscribe to broadcast events.
/// </summary>
public sealed class NetTexturesCleanupSystem : EntitySystem
{
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<RoundRestartCleanupEvent>(OnRoundRestart);
}
private void OnRoundRestart(RoundRestartCleanupEvent ev)
{
_netTexturesManager.ClearDynamicResources();
}
}

View file

@ -11,6 +11,8 @@ using Robust.Shared.Network.Transfer;
using Robust.Shared.Player;
using Robust.Shared.Upload;
using Robust.Shared.Utility;
using Robust.Shared.GameObjects;
using Content.Shared.GameTicking;
using ByteHelpers = Robust.Shared.Utility.ByteHelpers;
namespace Content.Server._Sunrise;
@ -20,7 +22,7 @@ namespace Content.Server._Sunrise;
/// Uses High Bandwidth Transfer (WebSocket) to avoid blocking main game traffic.
/// Textures are loaded into MemoryContentRoot on the client.
/// </summary>
public sealed class NetTexturesManager
public sealed class NetTexturesManager
{
/// <summary>
/// Transfer key for server -> client texture downloads via WebSocket
@ -32,25 +34,40 @@ public sealed class NetTexturesManager
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly ITransferManager _transferManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
private ISawmill _sawmill = default!;
private const string AllowedPrefix = "/NetTextures/";
/// <summary>
/// Dynamically registered in-memory resources that are not present on disk.
/// Key is the relative upload path used on the client (e.g. "NetTextures/Messenger/photo_123.png").
/// </summary>
private readonly Dictionary<ResPath, byte[]> _dynamicResources = new();
public void Initialize()
{
_sawmill = _logManager.GetSawmill("network.textures");
_netManager.RegisterNetMessage<RequestNetworkResourceMessage>(OnRequestNetworkResource);
// Register transfer key for High Bandwidth Transfer (WebSocket)
_transferManager.RegisterTransferMessage(TransferKeyNetTextures);
}
/// <summary>
/// Clears all dynamically registered in-memory resources.
/// Used during round restarts to prevent memory leaks.
/// </summary>
public void ClearDynamicResources()
{
_dynamicResources.Clear();
_sawmill.Info("Cleared all dynamic NetTexture resources due to round restart.");
}
private void OnRequestNetworkResource(RequestNetworkResourceMessage msg)
{
if (!_playerManager.TryGetSessionByChannel(msg.MsgChannel, out var session))
return;
// Normalize the path - ensure it's rooted
var resourcePath = msg.ResourcePath;
ResPath resPath;
@ -63,10 +80,8 @@ public sealed class NetTexturesManager
resPath = new ResPath("/") / resourcePath;
}
// Clean the path to remove any .. sequences
resPath = resPath.Clean();
// Validate the path to prevent path traversal attacks
if (!ValidateResourcePath(resPath, out var errorMessage))
{
_sawmill.Warning($"Rejected resource request from {session.Name}: {errorMessage} (path: {msg.ResourcePath})");
@ -84,15 +99,12 @@ public sealed class NetTexturesManager
{
errorMessage = null;
// Path must be rooted
if (!path.IsRooted)
{
errorMessage = "Path must be rooted";
return false;
}
// Check for dangerous path traversal sequences in the original string representation
// Even after Clean(), we should verify the path doesn't contain .. in segments
var pathStr = path.ToString();
if (pathStr.Contains("../") || pathStr.Contains("..\\") || pathStr.StartsWith(".."))
{
@ -100,16 +112,12 @@ public sealed class NetTexturesManager
return false;
}
// Only allow paths that start with /NetTextures/
// This ensures clients can only access resources from the NetTextures directory
if (!pathStr.StartsWith(AllowedPrefix, StringComparison.Ordinal))
{
errorMessage = $"Path must start with {AllowedPrefix}";
return false;
}
// Additional check: ensure the cleaned path doesn't escape the allowed directory
// by checking that it still starts with the allowed prefix after cleaning
var relativePath = path.ToRelativePath();
var relativePathStr = relativePath.ToString();
@ -132,16 +140,20 @@ public sealed class NetTexturesManager
var startTime = DateTime.UtcNow;
_sawmill.Debug($"[NetTextures] Starting transfer of {resourcePath} to {session.Name}");
// Collect all files to send
var filesToSend = new List<(ResPath Relative, byte[] Data)>();
// Check if it's a directory (RSI files are directories)
// Try to find files in the directory first
var relativeUploadPath = resourcePath.ToRelativePath();
if (_dynamicResources.TryGetValue(relativeUploadPath, out var dynamicData))
{
filesToSend.Add((relativeUploadPath, dynamicData));
}
else
{
var files = _resourceManager.ContentFindFiles(resourcePath).ToList();
if (files.Count == 0)
{
// No files found in directory, try as single file
if (!_resourceManager.ContentFileExists(resourcePath))
{
_sawmill.Warning($"Resource not found: {resourcePath}");
@ -153,13 +165,11 @@ public sealed class NetTexturesManager
}
else
{
// Directory - collect all files
foreach (var filePath in files)
{
if (!filePath.TryRelativeTo(resourcePath, out var relativePath))
continue;
// relativePath is guaranteed to be non-null here because TryRelativeTo returned true
var relativePathValue = relativePath.Value;
if (!_resourceManager.TryContentFileRead(filePath, out var stream))
@ -173,10 +183,8 @@ public sealed class NetTexturesManager
var data = new byte[stream.Length];
stream.Read(data, 0, data.Length);
// Calculate uploaded path: preserve the original path structure relative to Resources root
// Remove leading / and use as relative path for MemoryContentRoot
var relativeUploadPath = resourcePath.ToRelativePath();
var uploadedPath = relativeUploadPath / relativePathValue;
var relativeUploadPath2 = resourcePath.ToRelativePath();
var uploadedPath = relativeUploadPath2 / relativePathValue;
filesToSend.Add((uploadedPath, data));
}
@ -184,8 +192,8 @@ public sealed class NetTexturesManager
_sawmill.Debug($"Collected resource directory {resourcePath} ({files.Count} files) for {session.Name}");
}
}
// Send via High Bandwidth Transfer (WebSocket) to avoid blocking main game traffic
try
{
var transferStartTime = DateTime.UtcNow;
@ -205,7 +213,6 @@ public sealed class NetTexturesManager
catch (Exception ex)
{
_sawmill.Warning($"Failed to send resource via High Bandwidth Transfer to {session.Name}: {ex.Message}");
// Fallback to regular network message if WebSocket transfer fails
SendResourceFallback(session, filesToSend);
}
}
@ -226,7 +233,6 @@ public sealed class NetTexturesManager
var data = new byte[stream.Length];
stream.Read(data, 0, data.Length);
// Calculate uploaded path: preserve the original path structure relative to Resources root
var relativeUploadPath = filePath.ToRelativePath();
filesToSend.Add((relativeUploadPath, data));
}
@ -281,5 +287,49 @@ public sealed class NetTexturesManager
continueByte[0] = 0;
await stream.WriteAsync(continueByte);
}
/// <summary>
/// Registers a dynamic in-memory network texture that is not present on disk.
/// The resourcePath must point inside /NetTextures/ and will be validated by <see cref="ValidateResourcePath"/>.
/// </summary>
/// <param name="resourcePath">Rooted resource path, e.g. "/NetTextures/Messenger/photo_123.png".</param>
/// <param name="data">Raw file bytes (PNG, WEBP, etc.).</param>
public void RegisterDynamicResource(string resourcePath, byte[] data)
{
var path = resourcePath.StartsWith("/")
? new ResPath(resourcePath)
: new ResPath("/") / resourcePath;
path = path.Clean();
if (!ValidateResourcePath(path, out var error))
{
_sawmill.Warning($"Failed to register dynamic NetTexture {resourcePath}: {error}");
return;
}
var relativeUploadPath = path.ToRelativePath();
_dynamicResources[relativeUploadPath] = data;
_sawmill.Debug($"Registered dynamic NetTexture resource: {relativeUploadPath}");
}
/// <summary>
/// Unregisters a dynamic in-memory network texture.
/// </summary>
/// <param name="resourcePath">Rooted resource path, e.g. "/NetTextures/Messenger/photo_123.png".</param>
public void UnregisterDynamicResource(string resourcePath)
{
var path = resourcePath.StartsWith("/")
? new ResPath(resourcePath)
: new ResPath("/") / resourcePath;
path = path.Clean();
var relativeUploadPath = path.ToRelativePath();
if (_dynamicResources.Remove(relativeUploadPath))
{
_sawmill.Debug($"Unregistered dynamic NetTexture resource: {relativeUploadPath}");
}
}
}

View file

@ -33,7 +33,7 @@ public sealed partial class CartridgeLoaderComponent : Component
/// The maximum amount of programs that can be installed on the cartridge loader entity
/// </summary>
[DataField]
public int DiskSpace = 8;
public int DiskSpace = 12; // Sunrise-Edit
/// <summary>
/// Controls whether the cartridge loader will play notifications if it supports it at all

View file

@ -18,6 +18,7 @@ public sealed class MessengerUiMessageEvent : CartridgeMessageEvent
public readonly string? ChatId;
public readonly bool? IsMuted;
public readonly long? MessageId;
public readonly string? ImagePath;
public MessengerUiMessageEvent(
MessengerUiAction action,
@ -28,7 +29,8 @@ public sealed class MessengerUiMessageEvent : CartridgeMessageEvent
string? userId = null,
string? chatId = null,
bool? isMuted = null,
long? messageId = null)
long? messageId = null,
string? imagePath = null)
{
Action = action;
RecipientId = recipientId;
@ -39,6 +41,7 @@ public sealed class MessengerUiMessageEvent : CartridgeMessageEvent
ChatId = chatId;
IsMuted = isMuted;
MessageId = messageId;
ImagePath = imagePath;
}
}
@ -60,5 +63,6 @@ public enum MessengerUiAction
DeclineInvite,
LeaveGroup,
DeleteMessage,
TogglePin
TogglePin,
RequestPhotos
}

View file

@ -64,6 +64,11 @@ public sealed class MessengerUiState : BoundUserInterfaceState
/// </summary>
public HashSet<string> PinnedChats { get; }
/// <summary>
/// Галерея фотографий для выбора (опционально)
/// </summary>
public Dictionary<string, PhotoMetadata>? PhotoGallery { get; }
public MessengerUiState(
bool isRegistered,
bool serverAvailable,
@ -75,7 +80,8 @@ public sealed class MessengerUiState : BoundUserInterfaceState
HashSet<string> mutedGroupChats,
Dictionary<string, int> unreadCounts,
List<MessengerGroupInvite> activeInvites,
HashSet<string> pinnedChats)
HashSet<string> pinnedChats,
Dictionary<string, PhotoMetadata>? photoGallery = null)
{
IsRegistered = isRegistered;
ServerAvailable = serverAvailable;
@ -88,5 +94,6 @@ public sealed class MessengerUiState : BoundUserInterfaceState
UnreadCounts = unreadCounts;
ActiveInvites = activeInvites;
PinnedChats = pinnedChats;
PhotoGallery = photoGallery;
}
}

View file

@ -0,0 +1,44 @@
using Lidgren.Network;
using Robust.Shared.Network;
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.CartridgeLoader.Cartridges;
/// <summary>
/// Сообщение для отправки захваченного изображения с клиента на сервер
/// </summary>
public sealed class PdaPhotoCaptureMessage : NetMessage
{
public override MsgGroups MsgGroup => MsgGroups.String;
/// <summary>
/// Байты изображения (PNG или WebP)
/// </summary>
public byte[] ImageData { get; set; } = Array.Empty<byte>();
/// <summary>
/// Ширина изображения в пикселях
/// </summary>
public int Width { get; set; }
/// <summary>
/// Высота изображения в пикселях
/// </summary>
public int Height { get; set; }
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
{
Width = buffer.ReadInt32();
Height = buffer.ReadInt32();
var dataLength = buffer.ReadInt32();
ImageData = buffer.ReadBytes(dataLength);
}
public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
{
buffer.Write(Width);
buffer.Write(Height);
buffer.Write(ImageData.Length);
buffer.Write(ImageData);
}
}

View file

@ -0,0 +1,65 @@
using Robust.Shared.Serialization;
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
namespace Content.Shared._Sunrise.CartridgeLoader.Cartridges;
/// <summary>
/// Компонент фото-картриджа для КПК
/// </summary>
[RegisterComponent]
public sealed partial class PhotoCartridgeComponent : Component
{
/// <summary>
/// Галерея фотографий пользователя (PhotoId -> PhotoMetadata)
/// </summary>
[ViewVariables]
public Dictionary<string, PhotoMetadata> PhotoGallery = new();
/// <summary>
/// Звук срабатывания затвора
/// </summary>
[DataField]
public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/_Sunrise/camera_shot.ogg");
/// <summary>
/// Эффект вспышки
/// </summary>
[DataField]
public EntProtoId FlashEffect = "PhotoFlashEffect";
/// <summary>
/// Включена ли вспышка (пользовательская настройка)
/// </summary>
[DataField]
public bool FlashEnabled = true;
}
/// <summary>
/// Метаданные фотографии в галерее
/// </summary>
[Serializable, NetSerializable]
public sealed class PhotoMetadata
{
/// <summary>
/// Уникальный идентификатор фотографии (GUID)
/// </summary>
public string PhotoId { get; set; }
/// <summary>
/// Путь к сетевому ресурсу изображения (например, "/NetTextures/Messenger/photo_123.png")
/// </summary>
public string ImagePath { get; set; }
/// <summary>
/// Время создания фотографии (относительно начала раунда)
/// </summary>
public TimeSpan Timestamp { get; set; }
public PhotoMetadata(string photoId, string imagePath, TimeSpan timestamp)
{
PhotoId = photoId;
ImagePath = imagePath;
Timestamp = timestamp;
}
}

View file

@ -0,0 +1,45 @@
using Content.Shared.CartridgeLoader;
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.CartridgeLoader.Cartridges;
/// <summary>
/// Событие сообщения UI фото-картриджа
/// </summary>
[Serializable, NetSerializable]
public sealed class PhotoUiMessageEvent : CartridgeMessageEvent
{
public readonly PhotoUiAction Action;
public readonly string? PhotoId;
public readonly string? RecipientId;
public readonly string? GroupId;
public PhotoUiMessageEvent(
PhotoUiAction action,
string? photoId = null,
string? recipientId = null,
string? groupId = null,
bool? flashEnabled = null)
{
Action = action;
PhotoId = photoId;
RecipientId = recipientId;
GroupId = groupId;
FlashEnabled = flashEnabled;
}
public bool? FlashEnabled { get; }
}
/// <summary>
/// Действия UI фото-картриджа
/// </summary>
[Serializable, NetSerializable]
public enum PhotoUiAction
{
CapturePhoto,
SendPhotoToMessenger,
RequestGallery,
DeletePhoto,
ToggleFlash
}

View file

@ -0,0 +1,42 @@
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.CartridgeLoader.Cartridges;
/// <summary>
/// Состояние UI фото-картриджа
/// </summary>
[Serializable, NetSerializable]
public sealed class PhotoUiState : BoundUserInterfaceState
{
/// <summary>
/// Список фотографий в галерее (PhotoId -> PhotoMetadata)
/// </summary>
public Dictionary<string, PhotoMetadata> Photos { get; }
/// <summary>
/// Статус камеры (готовность к съемке)
/// </summary>
public bool CameraReady { get; }
/// <summary>
/// Включена ли вспышка
/// </summary>
public bool FlashEnabled { get; }
/// <summary>
/// Сообщение об ошибке (если есть)
/// </summary>
public string? ErrorMessage { get; }
public PhotoUiState(
Dictionary<string, PhotoMetadata> photos,
bool cameraReady,
bool flashEnabled,
string? errorMessage = null)
{
Photos = photos;
CameraReady = cameraReady;
FlashEnabled = flashEnabled;
ErrorMessage = errorMessage;
}
}

View file

@ -55,7 +55,23 @@ public sealed class MessengerMessage
/// </summary>
public ProtoId<JobIconPrototype>? SenderJobIconId { get; set; }
public MessengerMessage(string senderId, string senderName, string content, TimeSpan timestamp, string? groupId = null, string? recipientId = null, bool isRead = false, long messageId = 0, ProtoId<JobIconPrototype>? senderJobIconId = null)
/// <summary>
/// Путь к сетевому изображению, если сообщение содержит картинку.
/// Например: "/NetTextures/Messenger/photo_123.png"
/// </summary>
public string? ImagePath { get; set; }
public MessengerMessage(
string senderId,
string senderName,
string content,
TimeSpan timestamp,
string? groupId = null,
string? recipientId = null,
bool isRead = false,
long messageId = 0,
ProtoId<JobIconPrototype>? senderJobIconId = null,
string? imagePath = null)
{
SenderId = senderId;
SenderName = senderName;
@ -66,5 +82,6 @@ public sealed class MessengerMessage
IsRead = isRead;
MessageId = messageId;
SenderJobIconId = senderJobIconId;
ImagePath = imagePath;
}
}

Binary file not shown.

View file

@ -20,3 +20,5 @@ ent-NavigatorCartridge = navigator cartridge
.desc = A program for viewing the station map for navigation purposes.
ent-MessengerCartridge = messenger cartridge
.desc = A program for messaging between PDAs.
ent-PhotoCartridge = camera cartridge
.desc = A program for taking photos and managing a photo gallery.

View file

@ -0,0 +1,14 @@
photo-cartridge-photos-count = Photos: {$count}/{$max}
photo-cartridge-limit-reached = Photo limit reached
photo-cartridge-photo-not-found = Photo not found
photo-cartridge-messenger-unavailable = Messenger server unavailable
photo-cartridge-recipient-not-specified = Recipient not specified
photo-cartridge-photo-text = [Photo]
photo-cartridge-tab-camera = Camera
photo-cartridge-tab-gallery = Gallery
photo-cartridge-zoom-label = Zoom:
photo-cartridge-capture-button = Take Photo
photo-cartridge-overlay-button = 👁 Overlay
photo-cartridge-back-button = Back
photo-cartridge-delete-button = Delete
photo-cartridge-flash-label = Flash

View file

@ -6,7 +6,8 @@ nano-task-program-name = NanoTask
news-read-program-name = Station news
crew-manifest-program-name = Crew manifest
messenger-program-name = Messenger MAX
messenger-program-name = RobustChat
photo-program-name = FlashSnap
crew-manifest-cartridge-loading = Loading ...
net-probe-program-name = NetProbe

View file

@ -20,3 +20,5 @@ ent-NavigatorCartridge = картридж навигатора
.desc = Программа для просмотра карты станции в целях навигации.
ent-MessengerCartridge = картридж мессенджера
.desc = Программа для обмена сообщениями между КПК.
ent-PhotoCartridge = картридж фотоаппарата
.desc = Программа для съемки фотографий и управления галереей.

View file

@ -2,3 +2,18 @@ plant-analyzer-program-name = БотаТек
ent-PlantAnalyzerCartridge = картридж БотаТек
.desc = Программа, предоставляющая инструменты для анализа растений ботаникам.
photo-cartridge-photos-count = Фотографий: {$count}/{$max}
photo-cartridge-limit-reached = Достигнут лимит фотографий
photo-cartridge-photo-not-found = Фотография не найдена
photo-cartridge-messenger-unavailable = Сервер мессенджера недоступен
photo-cartridge-recipient-not-specified = Не указан получатель
photo-cartridge-photo-text = [Фото]
photo-cartridge-tab-camera = Камера
photo-cartridge-tab-gallery = Галерея
photo-cartridge-zoom-label = Зум:
photo-cartridge-capture-button = Сделать фото
photo-cartridge-overlay-button = 👁 Оверлей
photo-cartridge-back-button = Назад
photo-cartridge-delete-button = Удалить
photo-cartridge-flash-label = Вспышка

View file

@ -4,7 +4,7 @@ notekeeper-program-name = Заметки
nano-task-program-name = NanoTask
news-read-program-name = Новости станции
crew-manifest-program-name = Манифест экипажа
messenger-program-name = Мессенджер МАХ
messenger-program-name = RobustChat
crew-manifest-cartridge-loading = Загрузка...
net-probe-program-name = Зонд сетей
net-probe-scan = Просканирован { $device }!
@ -26,6 +26,8 @@ astro-nav-program-name = АстроНав
navigator-program-name = Навигатор
navigator-cartridge-loading = Загрузка карты...
photo-program-name = FlashSnap
med-tek-program-name = МедТек
# Wanted list cartridge
wanted-list-program-name = Список разыскиваемых

View file

@ -59,3 +59,7 @@ messenger-invite-user-to-group-title = Пригласить в { $groupName }
messenger-delete-message = Удалить сообщение
messenger-system-user-joined = { $userName } присоединился(ась) к группе
messenger-system-user-left = { $userName } покинул(а) группу
messenger-photo-button-tooltip = Отправить фотографию
messenger-photo-picker-title = Выберите фотографию
messenger-image-preview-title = Просмотр изображения
messenger-photo-flash-label = Вспышка

View file

@ -78,7 +78,7 @@
prefix: device-address-prefix-console
savableAddress: false
- type: WirelessNetworkConnection
range: 500
range: 2000 # Sunrise-Edit
- type: CartridgeLoader
uiKey: enum.PdaUiKey.Key
preinstalled:
@ -89,6 +89,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
cartridgeSlot:
priority: -1
@ -170,6 +171,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -188,6 +190,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -363,6 +366,7 @@
- PlantAnalyzerCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -548,6 +552,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -601,6 +606,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -1022,6 +1028,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -1108,6 +1115,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -1160,6 +1168,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: Tag # Ignore Chameleon tags
tags:
@ -1413,6 +1422,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -1602,6 +1612,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -1636,6 +1647,7 @@
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: entity
@ -1803,6 +1815,7 @@
- NavigatorCartridge
- AstroNavCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-end
- type: entity

View file

@ -0,0 +1,13 @@
- type: entity
id: PhotoFlashEffect
categories: [ HideSpawnMenu ]
components:
- type: PointLight
enabled: true
radius: 10
energy: 10
netsync: false
- type: LightFade
duration: 0.5
- type: TimedDespawn
lifetime: 0.5

View file

@ -26,6 +26,7 @@
- MedTekCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: PdaAnimationVisuals
animatedState: pda-space-prison-doctor
idInsertedLayerState: id_inserted-space-prison

View file

@ -21,6 +21,7 @@
- NetProbeCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: entity
parent: PrisonEngineerPDA

View file

@ -27,6 +27,7 @@
- NewsReaderCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: entity
parent: SecurityMetusPDA

View file

@ -31,6 +31,7 @@
- AstroNavCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: PdaAnimationVisuals
animatedState: pda-space-prison-pilot
idInsertedLayerState: id_inserted-space-prison

View file

@ -37,6 +37,7 @@
- NewsReaderCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: entity
parent: PlanetPrisonerPDA

View file

@ -26,6 +26,7 @@
- AstroNavCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: PdaAnimationVisuals
animatedState: pda-space-prison-scientist
idInsertedLayerState: id_inserted-space-prison

View file

@ -26,6 +26,7 @@
- AstroNavCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: PdaAnimationVisuals
animatedState: pda-space-prison-worker
idInsertedLayerState: id_inserted-space-prison

View file

@ -1,4 +1,4 @@
- type: entity
- type: entity
parent: BaseItem
id: PlantAnalyzerCartridge
name: PlantAnalyzerCartridge
@ -52,5 +52,22 @@
programName: messenger-program-name
icon:
sprite: _Sunrise/Interface/Misc/program_icons.rsi
state: max_logo2
state: robust_chat
- type: MessengerCartridge
- type: entity
parent: BasePDACartridge
id: PhotoCartridge
name: photo cartridge
description: A program for taking photos and managing a photo gallery.
components:
- type: Sprite
state: cart-y
- type: UIFragment
ui: !type:PhotoUi
- type: Cartridge
programName: photo-program-name
icon:
sprite: _Sunrise/Interface/Misc/program_icons.rsi
state: flash_snap
- type: PhotoCartridge

View file

@ -187,6 +187,7 @@
- AstroNavCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: entity
parent: CaptainPDA
@ -425,6 +426,7 @@
- AstroNavCartridge
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
- type: entity
parent: BasePDA

View file

@ -36,9 +36,7 @@
receiveFrequencyId: Messenger
autoConnect: false
- type: WirelessNetworkConnection
range: 500
- type: StationLimitedNetwork
allowNonStationPackets: true
range: 2000
- type: ApcPowerReceiver
powerLoad: 200
- type: DeviceNetworkRequiresPower

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

View file

@ -1,7 +1,7 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "discord: seraphimttt",
"copyright": "max icons - discord: seraphimttt. Other take here https://pixelexplosive.itch.io/pixel-art-ui-icon-pack-25-icons-32x32",
"size": {
"x": 32,
"y": 32
@ -15,6 +15,12 @@
},
{
"name": "max_logo3"
},
{
"name": "robust_chat"
},
{
"name": "flash_snap"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B