cherry pick Fix storage (#37714) (#3833)

* Optimise storage a quadrillion times (#37638)

* Optimise storage a quadrillion times

* How sweaty can we get

* Add fast angle checks

* Fix chunk indices

* Optimise the refresh method

Helps on client a lot as the clientside is suboptimal atm.

* Better name

* wawawewa

* Add single-angle path

* Okay FINE rider

* Fix storage (#37714)

The one path I forgot to get the relative index.

* cleanup ring box

* Fix 1x1 storage windows (#35985)

* fix stupid lunchbox error

---------

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Co-authored-by: deltanedas <@deltanedas:kde.org>
This commit is contained in:
deltanedas 2025-05-23 17:34:37 +01:00 committed by GitHub
parent 2cdfb98768
commit 496d60448a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 369 additions and 94 deletions

View File

@ -63,6 +63,8 @@ public sealed class StorageSystem : SharedStorageSystem
component.SavedLocations[loc.Key] = new(loc.Value);
}
UpdateOccupied((uid, component));
var uiDirty = !component.StoredItems.SequenceEqual(_oldStoredItems);
if (uiDirty && UI.TryGetOpenUi<StorageBoundUserInterface>(uid, StorageComponent.StorageUiKey.Key, out var storageBui))

View File

@ -42,6 +42,9 @@ public sealed class StorageWindow : BaseWindow
private ValueList<EntityUid> _contained = new();
private ValueList<EntityUid> _toRemove = new();
// Manually store this because you can't have a 0x0 GridContainer but we still need to add child controls for 1x1 containers.
private Vector2i _pieceGridSize;
private TextureButton? _backButton;
private bool _isDirty;
@ -408,11 +411,14 @@ public sealed class StorageWindow : BaseWindow
_contained.Clear();
_contained.AddRange(storageComp.Container.ContainedEntities.Reverse());
var width = boundingGrid.Width + 1;
var height = boundingGrid.Height + 1;
// Build the grid representation
if (_pieceGrid.Rows - 1 != boundingGrid.Height || _pieceGrid.Columns - 1 != boundingGrid.Width)
if (_pieceGrid.Rows != _pieceGridSize.Y || _pieceGrid.Columns != _pieceGridSize.X)
{
_pieceGrid.Rows = boundingGrid.Height + 1;
_pieceGrid.Columns = boundingGrid.Width + 1;
_pieceGrid.Rows = height;
_pieceGrid.Columns = width;
_controlGrid.Clear();
for (var y = boundingGrid.Bottom; y <= boundingGrid.Top; y++)
@ -430,6 +436,7 @@ public sealed class StorageWindow : BaseWindow
}
}
_pieceGridSize = new(width, height);
_toRemove.Clear();
// Remove entities no longer relevant / Update existing ones

View File

@ -5,6 +5,7 @@ using Content.Shared.Examine;
using Content.Shared.Item.ItemToggle.Components;
using Content.Shared.Storage;
using JetBrains.Annotations;
using Robust.Shared.Collections;
using Robust.Shared.Containers;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
@ -205,15 +206,21 @@ public abstract class SharedItemSystem : EntitySystem
public IReadOnlyList<Box2i> GetAdjustedItemShape(Entity<ItemComponent?> entity, Angle rotation, Vector2i position)
{
if (!Resolve(entity, ref entity.Comp))
return new Box2i[] { };
return [];
var adjustedShapes = new List<Box2i>();
GetAdjustedItemShape(adjustedShapes, entity, rotation, position);
return adjustedShapes;
}
public void GetAdjustedItemShape(List<Box2i> adjustedShapes, Entity<ItemComponent?> entity, Angle rotation, Vector2i position)
{
var shapes = GetItemShape(entity);
var boundingShape = shapes.GetBoundingBox();
var boundingCenter = ((Box2) boundingShape).Center;
var matty = Matrix3Helpers.CreateTransform(boundingCenter, rotation);
var drift = boundingShape.BottomLeft - matty.TransformBox(boundingShape).BottomLeft;
var adjustedShapes = new List<Box2i>();
foreach (var shape in shapes)
{
var transformed = matty.TransformBox(shape).Translated(drift);
@ -222,8 +229,6 @@ public abstract class SharedItemSystem : EntitySystem
adjustedShapes.Add(translated);
}
return adjustedShapes;
}
/// <summary>

View File

@ -43,13 +43,14 @@ using Robust.Shared.Serialization;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using Content.Shared.Rounding;
using Robust.Shared.Collections;
using Robust.Shared.Map.Enumerators;
namespace Content.Shared.Storage.EntitySystems;
public abstract class SharedStorageSystem : EntitySystem
{
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] protected readonly IGameTiming Timing = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] protected readonly IRobustRandom Random = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLog = default!;
@ -116,6 +117,10 @@ public abstract class SharedStorageSystem : EntitySystem
protected readonly List<string> CantFillReasons = [];
// Caching for various checks
private readonly Dictionary<Vector2i, ulong> _ignored = new();
private List<Box2i> _itemShape = new();
/// <inheritdoc />
public override void Initialize()
{
@ -184,6 +189,8 @@ public abstract class SharedStorageSystem : EntitySystem
return;
}
UpdateOccupied((container.Owner, storage));
if (!ItemFitsInGridLocation((itemEnt.Owner, itemEnt.Comp), (container.Owner, storage), loc))
{
ContainerSystem.Remove(itemEnt.Owner, container, force: true);
@ -238,6 +245,7 @@ public abstract class SharedStorageSystem : EntitySystem
private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
{
// TODO: This should update all entities in storage as well.
if (args.ByType.ContainsKey(typeof(ItemSizePrototype))
|| (args.Removed?.ContainsKey(typeof(ItemSizePrototype)) ?? false))
{
@ -267,6 +275,9 @@ public abstract class SharedStorageSystem : EntitySystem
{
storageComp.Container = ContainerSystem.EnsureContainer<Container>(uid, StorageComponent.ContainerId);
UpdateAppearance((uid, storageComp, null));
// Make sure the initial starting grid is okay.
UpdateOccupied((uid, storageComp));
}
/// <summary>
@ -342,7 +353,7 @@ public abstract class SharedStorageSystem : EntitySystem
/// <summary>
/// Tries to get the storage location of an item.
/// </summary>
public bool TryGetStorageLocation(Entity<ItemComponent?> itemEnt, [NotNullWhen(true)] out BaseContainer? container, out StorageComponent? storage, out ItemStorageLocation loc)
public bool TryGetStorageLocation(Entity<ItemComponent?> itemEnt, [NotNullWhen(true)] out BaseContainer? container, [NotNullWhen(true)] out StorageComponent? storage, out ItemStorageLocation loc)
{
loc = default;
storage = null;
@ -862,7 +873,7 @@ public abstract class SharedStorageSystem : EntitySystem
}
entity.Comp.StoredItems[args.Entity] = location.Value;
Dirty(entity, entity.Comp);
AddOccupiedEntity(entity, args.Entity, location.Value);
}
UpdateAppearance((entity, entity.Comp, null));
@ -878,7 +889,11 @@ public abstract class SharedStorageSystem : EntitySystem
if (args.Container.ID != StorageComponent.ContainerId)
return;
entity.Comp.StoredItems.Remove(args.Entity);
if (entity.Comp.StoredItems.Remove(args.Entity, out var loc))
{
RemoveOccupiedEntity(entity, args.Entity, loc);
}
Dirty(entity, entity.Comp);
UpdateAppearance((entity, entity.Comp, null));
@ -1074,7 +1089,7 @@ public abstract class SharedStorageSystem : EntitySystem
return false;
uid.Comp.StoredItems[insertEnt] = location;
Dirty(uid, uid.Comp);
AddOccupiedEntity((uid.Owner, uid.Comp), insertEnt, location);
if (Insert(uid,
insertEnt,
@ -1088,6 +1103,7 @@ public abstract class SharedStorageSystem : EntitySystem
return true;
}
RemoveOccupiedEntity((uid.Owner, uid.Comp), insertEnt, location);
uid.Comp.StoredItems.Remove(insertEnt);
return false;
}
@ -1250,9 +1266,14 @@ public abstract class SharedStorageSystem : EntitySystem
if (!ItemFitsInGridLocation(itemEnt, storageEnt, location.Position, location.Rotation))
return false;
storageEnt.Comp.StoredItems[itemEnt] = location;
if (storageEnt.Comp.StoredItems.Remove(itemEnt, out var existing))
{
RemoveOccupiedEntity((storageEnt.Owner, storageEnt.Comp), itemEnt, existing);
}
storageEnt.Comp.StoredItems.Add(itemEnt, location);
AddOccupiedEntity((storageEnt.Owner, storageEnt.Comp), itemEnt, location);
UpdateUI(storageEnt);
Dirty(storageEnt, storageEnt.Comp);
return true;
}
@ -1297,17 +1318,102 @@ public abstract class SharedStorageSystem : EntitySystem
}
}
for (var y = storageBounding.Bottom; y <= storageBounding.Top; y++)
// Ignore the item's existing location for fitting purposes.
_ignored.Clear();
if (storageEnt.Comp.StoredItems.TryGetValue(itemEnt.Owner, out var existing))
{
for (var x = storageBounding.Left; x <= storageBounding.Right; x++)
AddOccupied(itemEnt, existing, _ignored);
}
// This uses a faster path than the typical codepaths
// as we can cache a bunch more data and re-use it to avoid a bunch of component overhead.
// So if we have an item that occupies 0,0 we can assume that the tile itself we're checking
// is always in its shapes regardless of angle. This matches virtually every item in the game and
// means we can skip getting the item's rotated shape at all if the tile is occupied.
// This mostly makes heavy checks (e.g. area insert) much, much faster.
var fastPath = false;
var itemShape = ItemSystem.GetItemShape(itemEnt);
var fastAngles = itemShape.Count == 1;
foreach (var shape in itemShape)
{
if (shape.Contains(Vector2i.Zero))
{
for (var angle = startAngle; angle <= Angle.FromDegrees(360 - startAngle); angle += Math.PI / 2f)
fastPath = true;
break;
}
}
var chunkEnumerator = new ChunkIndicesEnumerator(storageBounding, StorageComponent.ChunkSize);
var angles = new ValueList<Angle>();
if (!fastAngles)
{
angles.Clear();
for (var angle = startAngle; angle <= Angle.FromDegrees(360 - startAngle); angle += Math.PI / 2f)
{
angles.Add(angle);
}
}
else
{
var shape = itemShape[0];
// At least 1 check for a square.
angles.Add(startAngle);
// If it's a rectangle make it 2.
if (shape.Width != shape.Height)
{
// Idk if there's a preferred facing but + or - 90 pick one.
angles.Add(startAngle + Angle.FromDegrees(90));
}
}
while (chunkEnumerator.MoveNext(out var storageChunk))
{
var storageChunkOrigin = storageChunk.Value * StorageComponent.ChunkSize;
var left = Math.Max(storageChunkOrigin.X, storageBounding.Left);
var bottom = Math.Max(storageChunkOrigin.Y, storageBounding.Bottom);
var top = Math.Min(storageChunkOrigin.Y + StorageComponent.ChunkSize - 1, storageBounding.Top);
var right = Math.Min(storageChunkOrigin.X + StorageComponent.ChunkSize - 1, storageBounding.Right);
// No data so assume empty.
if (!storageEnt.Comp.OccupiedGrid.TryGetValue(storageChunkOrigin, out var occupied))
continue;
// This has a lot of redundant tile checks but with the fast path it shouldn't matter for average ss14
// use cases.
for (var y = bottom; y <= top; y++)
{
for (var x = left; x <= right; x++)
{
var location = new ItemStorageLocation(angle, (x, y));
if (ItemFitsInGridLocation(itemEnt, storageEnt, location))
foreach (var angle in angles)
{
storageLocation = location;
return true;
var position = new Vector2i(x, y);
// This bit of code is how area inserts go from tanking frames to being negligible.
if (fastPath)
{
var flag = SharedMapSystem.ToBitmask(SharedMapSystem.GetChunkRelative(position, StorageComponent.ChunkSize), StorageComponent.ChunkSize);
// Occupied so skip.
if ((occupied & flag) == flag)
continue;
}
_itemShape.Clear();
ItemSystem.GetAdjustedItemShape(_itemShape, itemEnt, angle, position);
if (ItemFitsInGridLocation(storageEnt.Comp.OccupiedGrid, _itemShape, _ignored))
{
storageLocation = new ItemStorageLocation(angle, position);
return true;
}
}
}
}
@ -1398,6 +1504,59 @@ public abstract class SharedStorageSystem : EntitySystem
return ItemFitsInGridLocation(itemEnt, storageEnt, location.Position, location.Rotation);
}
private bool ItemFitsInGridLocation(
Dictionary<Vector2i, ulong> occupied,
IReadOnlyList<Box2i> itemShape,
Dictionary<Vector2i, ulong> ignored)
{
// We pre-cache the occupied / ignored tiles upfront and then can just check each tile 1-by-1.
// We do it by chunk so we can avoid dictionary overhead.
foreach (var box in itemShape)
{
var chunkEnumerator = new ChunkIndicesEnumerator(box, StorageComponent.ChunkSize);
while (chunkEnumerator.MoveNext(out var chunk))
{
var chunkOrigin = chunk.Value * StorageComponent.ChunkSize;
// Box may not necessarily be in 1 chunk so clamp it.
var left = Math.Max(chunkOrigin.X, box.Left);
var bottom = Math.Max(chunkOrigin.Y, box.Bottom);
var right = Math.Min(chunkOrigin.X + StorageComponent.ChunkSize - 1, box.Right);
var top = Math.Min(chunkOrigin.Y + StorageComponent.ChunkSize - 1, box.Top);
// Assume it's occupied if no data.
if (!occupied.TryGetValue(chunkOrigin, out var occupiedMask))
{
return false;
}
var ignoredMask = ignored.GetValueOrDefault(chunkOrigin);
for (var x = left; x <= right; x++)
{
for (var y = bottom; y <= top; y++)
{
var index = new Vector2i(x, y);
var chunkRelative = SharedMapSystem.GetChunkRelative(index, StorageComponent.ChunkSize);
var flag = SharedMapSystem.ToBitmask(chunkRelative, StorageComponent.ChunkSize);
// Ignore it
if ((ignoredMask & flag) == flag)
continue;
if ((occupiedMask & flag) == flag)
{
return false;
}
}
}
}
}
return true;
}
/// <summary>
/// Checks if an item fits into a specific spot on a storage grid.
/// </summary>
@ -1415,62 +1574,157 @@ public abstract class SharedStorageSystem : EntitySystem
return false;
var itemShape = ItemSystem.GetAdjustedItemShape(itemEnt, rotation, position);
// Ignore the item's existing location for fitting purposes.
_ignored.Clear();
foreach (var box in itemShape)
if (storageEnt.Comp.StoredItems.TryGetValue(itemEnt.Owner, out var existing))
{
for (var offsetY = box.Bottom; offsetY <= box.Top; offsetY++)
{
for (var offsetX = box.Left; offsetX <= box.Right; offsetX++)
{
var pos = (offsetX, offsetY);
AddOccupied(itemEnt, existing, _ignored);
}
if (!IsGridSpaceEmpty(itemEnt, storageEnt, pos))
return false;
}
}
return ItemFitsInGridLocation(storageEnt.Comp.OccupiedGrid, itemShape, _ignored);
}
/// <summary>
/// Checks if a space on a grid is valid and not occupied by any other pieces.
/// </summary>
public bool IsGridSpaceEmpty(Entity<StorageComponent?> storageEnt, Vector2i location, Dictionary<Vector2i, ulong>? ignored = null)
{
if (!Resolve(storageEnt, ref storageEnt.Comp))
return false;
var chunkOrigin = SharedMapSystem.GetChunkIndices(location, StorageComponent.ChunkSize) * StorageComponent.ChunkSize;
// No entry so assume it's occupied.
if (!storageEnt.Comp.OccupiedGrid.TryGetValue(chunkOrigin, out var occupiedMask))
return false;
var chunkRelative = SharedMapSystem.GetChunkRelative(location, StorageComponent.ChunkSize);
var occupiedIndex = SharedMapSystem.ToBitmask(chunkRelative);
if (ignored?.TryGetValue(chunkOrigin, out var ignoredMask) == true && (ignoredMask & occupiedIndex) == occupiedIndex)
{
return true;
}
if ((occupiedMask & occupiedIndex) != 0x0)
{
return false;
}
return true;
}
/// <summary>
/// Checks if a space on a grid is valid and not occupied by any other pieces.
/// Updates the occupied grid mask for the entity.
/// </summary>
public bool IsGridSpaceEmpty(Entity<ItemComponent?> itemEnt, Entity<StorageComponent?> storageEnt, Vector2i location)
protected void UpdateOccupied(Entity<StorageComponent> ent)
{
if (!Resolve(storageEnt, ref storageEnt.Comp))
return false;
ent.Comp.OccupiedGrid.Clear();
RemoveOccupied(ent.Comp.Grid, ent.Comp.OccupiedGrid);
var validGrid = false;
foreach (var grid in storageEnt.Comp.Grid)
Dirty(ent);
foreach (var (stent, storedItem) in ent.Comp.StoredItems)
{
if (grid.Contains(location))
{
validGrid = true;
break;
}
}
if (!validGrid)
return false;
foreach (var (ent, storedItem) in storageEnt.Comp.StoredItems)
{
if (ent == itemEnt.Owner)
if (!_itemQuery.TryGetComponent(stent, out var itemComp))
continue;
if (!_itemQuery.TryGetComponent(ent, out var itemComp))
continue;
AddOccupiedEntity(ent, (stent, itemComp), storedItem);
}
}
var adjustedShape = ItemSystem.GetAdjustedItemShape((ent, itemComp), storedItem);
foreach (var box in adjustedShape)
private void AddOccupiedEntity(Entity<StorageComponent> storageEnt, Entity<ItemComponent?> itemEnt, ItemStorageLocation location)
{
AddOccupied(itemEnt, location, storageEnt.Comp.OccupiedGrid);
Dirty(storageEnt);
}
private void AddOccupied(Entity<ItemComponent?> itemEnt, ItemStorageLocation location, Dictionary<Vector2i, ulong> occupied)
{
var adjustedShape = ItemSystem.GetAdjustedItemShape((itemEnt.Owner, itemEnt.Comp), location);
AddOccupied(adjustedShape, occupied);
}
private void RemoveOccupied(IReadOnlyList<Box2i> adjustedShape, Dictionary<Vector2i, ulong> occupied)
{
foreach (var box in adjustedShape)
{
var chunks = new ChunkIndicesEnumerator(box, StorageComponent.ChunkSize);
while (chunks.MoveNext(out var chunk))
{
if (box.Contains(location))
return false;
var chunkOrigin = chunk.Value * StorageComponent.ChunkSize;
var left = Math.Max(box.Left, chunkOrigin.X);
var bottom = Math.Max(box.Bottom, chunkOrigin.Y);
var right = Math.Min(box.Right, chunkOrigin.X + StorageComponent.ChunkSize - 1);
var top = Math.Min(box.Top, chunkOrigin.Y + StorageComponent.ChunkSize - 1);
var existing = occupied.GetValueOrDefault(chunkOrigin, ulong.MaxValue);
// Unmark all of the tiles that we actually have.
for (var x = left; x <= right; x++)
{
for (var y = bottom; y <= top; y++)
{
var index = new Vector2i(x, y);
var chunkRelative = SharedMapSystem.GetChunkRelative(index, StorageComponent.ChunkSize);
var flag = SharedMapSystem.ToBitmask(chunkRelative, StorageComponent.ChunkSize);
existing &= ~flag;
}
}
// My kingdom for collections.marshal
occupied[chunkOrigin] = existing;
}
}
}
return true;
private void AddOccupied(IReadOnlyList<Box2i> adjustedShape, Dictionary<Vector2i, ulong> occupied)
{
foreach (var box in adjustedShape)
{
// Reduce dictionary access from every tile to just once per chunk.
// Makes this more complicated but dictionaries are slow af.
// This is how we get savings over IsGridSpaceEmpty.
var chunkEnumerator = new ChunkIndicesEnumerator(box, StorageComponent.ChunkSize);
while (chunkEnumerator.MoveNext(out var chunk))
{
var chunkOrigin = chunk.Value * StorageComponent.ChunkSize;
var existing = occupied.GetOrNew(chunkOrigin);
// Box may not necessarily be in 1 chunk so clamp it.
var left = Math.Max(chunkOrigin.X, box.Left);
var bottom = Math.Max(chunkOrigin.Y, box.Bottom);
var right = Math.Min(chunkOrigin.X + StorageComponent.ChunkSize - 1, box.Right);
var top = Math.Min(chunkOrigin.Y + StorageComponent.ChunkSize - 1, box.Top);
for (var x = left; x <= right; x++)
{
for (var y = bottom; y <= top; y++)
{
var index = new Vector2i(x, y);
var chunkRelative = SharedMapSystem.GetChunkRelative(index, StorageComponent.ChunkSize);
var flag = SharedMapSystem.ToBitmask(chunkRelative, StorageComponent.ChunkSize);
existing |= flag;
}
}
occupied[chunkOrigin] = existing;
}
}
}
private void RemoveOccupiedEntity(Entity<StorageComponent> storageEnt, Entity<ItemComponent?> itemEnt, ItemStorageLocation location)
{
var adjustedShape = ItemSystem.GetAdjustedItemShape((itemEnt.Owner, itemEnt.Comp), location);
RemoveOccupied(adjustedShape, storageEnt.Comp.OccupiedGrid);
Dirty(storageEnt);
}
/// <summary>

View File

@ -19,6 +19,14 @@ namespace Content.Shared.Storage
{
public static string ContainerId = "storagebase";
public const byte ChunkSize = 8;
// No datafield because we can just derive it from stored items.
/// <summary>
/// Bitmask of occupied tiles
/// </summary>
public Dictionary<Vector2i, ulong> OccupiedGrid = new();
[ViewVariables]
public Container Container = default!;

View File

@ -18,8 +18,7 @@
- type: Storage
maxItemSize: Normal
grid:
- 0,0,1,1
- 3,0,1,1
- 0,0,2,1
- 4,0,4,1
- type: PhysicalComposition
materialComposition:
@ -44,70 +43,70 @@
contents:
#Main
- id: FoodPizzaArnoldSlice
orGroup: HealthyOrUnhealthyMain
orGroup: Main
prob: 0.2
amount: 2
- id: FoodBurgerCheese
orGroup: HealthyOrUnhealthyMain
orGroup: Main
prob: 0.2
- id: FoodCarrot
orGroup: HealthyOrUnhealthyMain
orGroup: Main
prob: 0.2
- id: FoodMothCapreseSalad
orGroup: HealthyOrUnhealthyMain
orGroup: Main
prob: 0.2
- id: FoodEggBoiled
orGroup: HealthyOrUnhealthyMain
orGroup: Main
prob: 0.2
#Drink
- id: DrinkJuiceOrangeJuicebox
orGroup: HealthyOrUnhealthyDrink
orGroup: Drink
prob: 0.15
- id: DrinkJuicePineappleJuicebox
orGroup: HealthyOrUnhealthyDrink
orGroup: Drink
prob: 0.15
- id: DrinkJuiceAppleJuicebox
orGroup: HealthyOrUnhealthyDrink
orGroup: Drink
prob: 0.15
- id: DrinkJuiceGrapeJuicebox
orGroup: HealthyOrUnhealthyDrink
orGroup: Drink
prob: 0.15
- id: DrinkChocolateJuicebox
orGroup: HealthyOrUnhealthyDrink
orGroup: Drink
prob: 0.15
- id: DrinkWaterBottleFull
orGroup: HealthyOrUnhealthyDrink
orGroup: Drink
prob: 0.15
#Snack
- id: FoodSnackCheesie
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
- id: FoodSnackBoritos
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
- id: FoodSnackChips
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
- id: FoodSnackPistachios
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
- id: FoodSnackChocolate
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
- id: FoodSnackSus
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
- id: FoodMothMoffin
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.05
- id: FoodMothMothmallowSlice
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.05
- id: FoodApple
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
- id: FoodBanana
orGroup: HealthyOrUnhealthySnack
orGroup: Snack
prob: 0.15
#Note
- id: PaperWrittenNoteFromMumGeneric

View File

@ -4,17 +4,17 @@
name: ring box
description: Made from a high quality wood!
components:
- type: Sprite
sprite: _DV/Objects/Specific/Chapel/ringbox.rsi
layers:
- state: ring-box-closed
map: [ "closeLayer" ]
- state: ring-box-open
map: [ "openLayer" ]
visible: false
- type: StaticPrice
price: 50
- type: Appearance
- type: Storage # No restrictions on purpose. If someone wants to propose with a clown horn let them!
grid:
- 0,0,0,0
- type: Sprite
sprite: _DV/Objects/Specific/Chapel/ringbox.rsi
layers:
- state: ring-box-closed
map: [ "closeLayer" ]
- state: ring-box-open
map: [ "openLayer" ]
visible: false
- type: StaticPrice
price: 50
- type: Appearance
- type: Storage # No whitelist on purpose. If someone wants to propose with a clown horn let them!
grid:
- 0,0,0,0