diff --git a/code/__DEFINES/aquarium.dm b/code/__DEFINES/aquarium.dm
new file mode 100644
index 00000000000..735c9a90a82
--- /dev/null
+++ b/code/__DEFINES/aquarium.dm
@@ -0,0 +1,27 @@
+#define AQUARIUM_ANIMATION_FISH_SWIM "fish"
+#define AQUARIUM_ANIMATION_FISH_DEAD "dead"
+
+#define AQUARIUM_PROPERTIES_PX_MIN "px_min"
+#define AQUARIUM_PROPERTIES_PX_MAX "px_max"
+#define AQUARIUM_PROPERTIES_PY_MIN "py_min"
+#define AQUARIUM_PROPERTIES_PY_MAX "py_max"
+
+#define AQUARIUM_LAYER_MODE_BOTTOM "bottom"
+#define AQUARIUM_LAYER_MODE_TOP "top"
+#define AQUARIUM_LAYER_MODE_AUTO "auto"
+
+#define FISH_ALIVE "alive"
+#define FISH_DEAD "dead"
+
+#define MIN_AQUARIUM_TEMP T0C
+#define MAX_AQUARIUM_TEMP T0C + 100
+
+#define FISH_RARITY_BASIC 1000
+#define FISH_RARITY_RARE 400
+#define FISH_RARITY_VERY_RARE 200
+#define FISH_RARITY_GOOD_LUCK_FINDING_THIS 1
+
+#define AQUARIUM_FLUID_FRESHWATER "Freshwater"
+#define AQUARIUM_FLUID_SALTWATER "Saltwater"
+#define AQUARIUM_FLUID_SULPHWATEVER "Sulphuric Water"
+#define AQUARIUM_FLUID_AIR "Air"
diff --git a/code/__DEFINES/dcs/signals.dm b/code/__DEFINES/dcs/signals.dm
index dde65cf58be..2b75057dd0d 100644
--- a/code/__DEFINES/dcs/signals.dm
+++ b/code/__DEFINES/dcs/signals.dm
@@ -1000,3 +1000,9 @@
#define COMSIG_HUMAN_EARLY_UNARMED_ATTACK "human_early_unarmed_attack"
///from mob/living/carbon/human/UnarmedAttack(): (atom/target, proximity)
#define COMSIG_HUMAN_MELEE_UNARMED_ATTACK "human_melee_unarmed_attack"
+
+
+// Aquarium related signals
+#define COMSIG_AQUARIUM_BEFORE_INSERT_CHECK "aquarium_about_to_be_inserted"
+#define COMSIG_AQUARIUM_SURFACE_CHANGED "aquarium_surface_changed"
+#define COMSIG_AQUARIUM_FLUID_CHANGED "aquarium_fluid_changed"
diff --git a/code/__DEFINES/traits.dm b/code/__DEFINES/traits.dm
index 5d508344dda..500ead5a708 100644
--- a/code/__DEFINES/traits.dm
+++ b/code/__DEFINES/traits.dm
@@ -318,6 +318,8 @@ Remember to update _globalvars/traits.dm if you're adding/removing/renaming trai
#define TRAIT_NO_TELEPORT "no-teleport" //you just can't
#define TRAIT_FOOD_GRILLED "food_grilled"
#define TRAIT_NEEDS_TWO_HANDS "needstwohands" //The items needs two hands to be carried
+#define TRAIT_FISH_SAFE_STORAGE "fish_case" //Fish in this won't die
+#define TRAIT_FISH_CASE_COMPATIBILE "fish_case_compatibile" //Stuff that can go inside fish cases
//quirk traits
#define TRAIT_ALCOHOL_TOLERANCE "alcohol_tolerance"
diff --git a/code/datums/components/aquarium.dm b/code/datums/components/aquarium.dm
new file mode 100644
index 00000000000..f3b64aea165
--- /dev/null
+++ b/code/datums/components/aquarium.dm
@@ -0,0 +1,212 @@
+/// Allows movables to be inserted/displayed in aquariums.
+/datum/component/aquarium_content
+ /// This is a datum that describes our in-aquarium functionality
+ var/datum/aquarium_behaviour/properties
+
+ /// Keeps track of our current aquarium.
+ var/obj/structure/aquarium/current_aquarium
+
+ //This is visual effect holder that will end up in aquarium's vis_contents
+ var/obj/effect/vc_obj
+
+ /// Base px offset of the visual object in current aquarium aka current base position
+ var/base_px = 0
+ /// Base px offset of the visual object in current aquarium aka current base position
+ var/base_py = 0
+ //Current layer for the visual object
+ var/base_layer
+
+/datum/component/aquarium_content/Initialize(property_type)
+ if(!ismovable(parent))
+ return COMPONENT_INCOMPATIBLE
+ if(ispath(property_type, /datum/aquarium_behaviour))
+ properties = new property_type
+ else
+ CRASH("Invalid property type provided for aquarium content component")
+ properties.parent = src
+
+ ADD_TRAIT(parent, TRAIT_FISH_CASE_COMPATIBILE, src)
+ RegisterSignal(parent, COMSIG_MOVABLE_MOVED, .proc/enter_aquarium)
+
+/datum/component/aquarium_content/PreTransfer()
+ . = ..()
+ REMOVE_TRAIT(parent, TRAIT_FISH_CASE_COMPATIBILE, src)
+
+/datum/component/aquarium_content/Destroy(force, silent)
+ if(current_aquarium)
+ remove_from_aquarium()
+ QDEL_NULL(vc_obj)
+ QDEL_NULL(properties)
+ return ..()
+
+/datum/component/aquarium_content/proc/enter_aquarium(datum/source, OldLoc, Dir, Forced)
+ SIGNAL_HANDLER
+ var/atom/movable/movable_parent = parent
+ if(istype(movable_parent.loc, /obj/structure/aquarium))
+ on_inserted(movable_parent.loc)
+ if(HAS_TRAIT(movable_parent.loc, TRAIT_FISH_SAFE_STORAGE))
+ on_tank_stasis()
+
+/datum/component/aquarium_content/proc/is_ready_to_insert(obj/structure/aquarium/aquarium)
+ //This is kinda awful but we're unaware of other fish
+ if(properties.unique)
+ for(var/atom/movable/fish_or_prop in aquarium)
+ if(fish_or_prop == parent)
+ continue
+ var/datum/component/aquarium_content/other_content = fish_or_prop.GetComponent(/datum/component/aquarium_content)
+ if(other_content && other_content.properties.type == properties.type)
+ return FALSE
+ return TRUE
+
+/datum/component/aquarium_content/proc/on_inserted(atom/aquarium)
+ current_aquarium = aquarium
+ RegisterSignal(current_aquarium, COMSIG_ATOM_EXITED, .proc/on_removed)
+ RegisterSignal(current_aquarium, COMSIG_AQUARIUM_SURFACE_CHANGED, .proc/on_surface_changed)
+ RegisterSignal(current_aquarium, COMSIG_AQUARIUM_FLUID_CHANGED,.proc/on_fluid_changed)
+ RegisterSignal(current_aquarium, COMSIG_PARENT_ATTACKBY, .proc/attack_reaction)
+ properties.on_inserted()
+
+ //If we don't have vc object yet build it
+ if(!vc_obj)
+ vc_obj = generate_base_vc()
+
+ //Set default position and layer
+ set_vc_base_position()
+ generate_animation()
+
+ //Finally add it to to objects vis_contents
+ current_aquarium.vis_contents |= vc_obj
+
+
+/// Aquarium surface changed in some way, we need to recalculate base position and aninmation
+/datum/component/aquarium_content/proc/on_surface_changed()
+ SIGNAL_HANDLER
+ set_vc_base_position()
+ generate_animation() //our animation start point changed, gotta redo
+
+/// Our aquarium is hit with stuff
+/datum/component/aquarium_content/proc/attack_reaction(datum/source, obj/item/thing, mob/user, params)
+ SIGNAL_HANDLER
+ if(istype(thing, /obj/item/fish_feed))
+ properties.on_feeding(thing.reagents)
+ return COMPONENT_NO_AFTERATTACK
+ else
+ //stirred effect
+ generate_animation()
+
+/datum/component/aquarium_content/proc/on_fluid_changed()
+ SIGNAL_HANDLER
+ properties.on_fluid_changed()
+
+/datum/component/aquarium_content/proc/remove_visual_from_aquarium()
+ current_aquarium.vis_contents -= vc_obj
+ if(base_layer)
+ current_aquarium.free_layer(base_layer)
+
+
+/// Generates common visual object, propeties that don't depend on aquarium surface
+/datum/component/aquarium_content/proc/generate_base_vc()
+ if(!properties)
+ CRASH("Generating visual without properties.")
+
+ var/obj/effect/visual = new
+ properties.apply_appearance(visual)
+ visual.vis_flags |= VIS_INHERIT_ID | VIS_INHERIT_PLANE //plane so it shows properly in containers on inventory ui for handheld cases
+ return visual
+
+/// Actually animates the vc holder
+/datum/component/aquarium_content/proc/generate_animation()
+ switch(properties.current_animation)
+ if(AQUARIUM_ANIMATION_FISH_SWIM)
+ swim_animation()
+ return
+ if(AQUARIUM_ANIMATION_FISH_DEAD)
+ dead_animation()
+ return
+
+
+/// Create looping random path animation, pixel offsets parameters include offsets already
+/datum/component/aquarium_content/proc/swim_animation()
+ var/avg_width = round(properties.sprite_width / 2)
+ var/avg_height = round(properties.sprite_height / 2)
+
+ var/list/aq_properties = current_aquarium.get_surface_properties()
+ var/px_min = aq_properties[AQUARIUM_PROPERTIES_PX_MIN] + avg_width - 16
+ var/px_max = aq_properties[AQUARIUM_PROPERTIES_PX_MAX] - avg_width - 16
+ var/py_min = aq_properties[AQUARIUM_PROPERTIES_PY_MIN] + avg_height - 16
+ var/py_max = aq_properties[AQUARIUM_PROPERTIES_PY_MAX] - avg_width - 16
+
+ var/origin_x = base_px
+ var/origin_y = base_py
+ var/prev_x = origin_x
+ var/prev_y = origin_y
+ animate(vc_obj, pixel_x = origin_x, time = 0, loop = -1) //Just to start the animation
+ var/move_number = rand(3, 5) //maybe unhardcode this
+ for(var/i in 1 to move_number)
+ //If it's last movement, move back to start otherwise move to some random point
+ var/target_x = i == move_number ? origin_x : rand(px_min,px_max) //could do with enforcing minimal delta for prettier zigzags
+ var/target_y = i == move_number ? origin_y : rand(py_min,py_max)
+ var/dx = prev_x - target_x
+ var/dy = prev_y - target_y
+ prev_x = target_x
+ prev_y = target_y
+ var/dist = abs(dx) + abs(dy)
+ var/eyeballed_time = dist * 2 //2ds per px
+ //Face the direction we're going
+ var/matrix/dir_mx = properties.base_transform()
+ if(dx <= 0) //assuming default sprite is facing left here
+ dir_mx.Scale(-1, 1)
+ animate(transform = dir_mx, time = 0, loop = -1)
+ animate(pixel_x = target_x, pixel_y = target_y, time = eyeballed_time, loop = -1)
+
+/datum/component/aquarium_content/proc/dead_animation()
+ //Set base_py to lowest possible value
+ var/avg_height = round(properties.sprite_height / 2)
+ var/list/aq_properties = current_aquarium.get_surface_properties()
+ var/py_min = aq_properties[AQUARIUM_PROPERTIES_PY_MIN] + avg_height - 16
+ base_py = py_min
+ animate(vc_obj, pixel_y = py_min, time = 1) //flop to bottom and end current animation.
+
+
+/// Floating to top animation.
+/datum/component/aquarium_content/proc/float_animation()
+ return
+
+
+/datum/component/aquarium_content/proc/set_vc_base_position()
+ var/list/aq_properties = current_aquarium.get_surface_properties()
+ if(properties.randomize_position)
+ var/avg_width = round(properties.sprite_width / 2)
+ var/avg_height = round(properties.sprite_height / 2)
+ var/px_min = aq_properties[AQUARIUM_PROPERTIES_PX_MIN] + avg_width - 16
+ var/px_max = aq_properties[AQUARIUM_PROPERTIES_PX_MAX] - avg_width - 16
+ var/py_min = aq_properties[AQUARIUM_PROPERTIES_PY_MIN] + avg_height - 16
+ var/py_max = aq_properties[AQUARIUM_PROPERTIES_PY_MAX] - avg_width - 16
+
+ base_px = rand(px_min,px_max)
+ base_py = rand(py_min,py_max)
+
+ vc_obj.pixel_x = base_px
+ vc_obj.pixel_y = base_py
+
+ if(base_layer)
+ current_aquarium.free_layer(base_layer)
+ base_layer = current_aquarium.request_layer(properties.layer_mode)
+ vc_obj.layer = base_layer
+
+/datum/component/aquarium_content/proc/on_removed(datum/source, atom/movable/mover)
+ SIGNAL_HANDLER
+ if(mover != parent)
+ return
+ remove_from_aquarium()
+
+/datum/component/aquarium_content/proc/remove_from_aquarium()
+ properties.before_removal()
+ UnregisterSignal(current_aquarium, list(COMSIG_AQUARIUM_SURFACE_CHANGED, COMSIG_AQUARIUM_FLUID_CHANGED, COMSIG_PARENT_ATTACKBY, COMSIG_ATOM_EXITED))
+ remove_visual_from_aquarium()
+ current_aquarium = null
+ //We do not stop processing properties here. We want fish to die outside of aquariums after first insert. We only stop processing in properties.death or destroy
+
+/datum/component/aquarium_content/proc/on_tank_stasis()
+ // Stop processing until inserted into aquarium again.
+ STOP_PROCESSING(SSobj, properties)
diff --git a/code/datums/components/crafting/recipes.dm b/code/datums/components/crafting/recipes.dm
index bfd953e2684..472b098738e 100644
--- a/code/datums/components/crafting/recipes.dm
+++ b/code/datums/components/crafting/recipes.dm
@@ -1076,3 +1076,13 @@
/obj/item/grenade/gas_crystal/zauker_crystal = 1
)
category = CAT_MISC
+
+/datum/crafting_recipe/aquarium
+ name = "Aquarium"
+ result = /obj/structure/aquarium
+ time = 10 SECONDS
+ reqs = list(/obj/item/stack/sheet/metal = 15,
+ /obj/item/stack/sheet/glass = 10,
+ /obj/item/aquarium_kit = 1
+ )
+ category = CAT_MISC
diff --git a/code/datums/components/storage/concrete/fish_case.dm b/code/datums/components/storage/concrete/fish_case.dm
new file mode 100644
index 00000000000..4b74aa8c09b
--- /dev/null
+++ b/code/datums/components/storage/concrete/fish_case.dm
@@ -0,0 +1,10 @@
+/datum/component/storage/concrete/fish_case
+ max_items = 1
+ can_hold_trait = TRAIT_FISH_CASE_COMPATIBILE
+ can_hold_description = "fish and aquarium equipment"
+
+/datum/component/storage/concrete/fish_case/can_be_inserted(obj/item/I, stop_messages, mob/M)
+ /// Activate deferred components if any.
+ SEND_SIGNAL(I, COMSIG_AQUARIUM_BEFORE_INSERT_CHECK)
+ . = ..()
+
diff --git a/code/datums/components/storage/storage.dm b/code/datums/components/storage/storage.dm
index 536562a7344..adf3dd34747 100644
--- a/code/datums/components/storage/storage.dm
+++ b/code/datums/components/storage/storage.dm
@@ -8,7 +8,9 @@
var/list/can_hold //if this is set, only items, and their children, will fit
var/list/cant_hold //if this is set, items, and their children, won't fit
- var/list/exception_hold //if set, these items will be the exception to the max size of object that can fit.
+ var/list/exception_hold //if set, these items will be the exception to the max size of object that can fit.
+ /// If set can only contain stuff with this single trait present.
+ var/list/can_hold_trait
var/can_hold_description
@@ -630,7 +632,7 @@
if(!stop_messages)
to_chat(M, "[host] cannot hold [I]!")
return FALSE
- if(is_type_in_typecache(I, cant_hold) || HAS_TRAIT(I, TRAIT_NO_STORAGE_INSERT)) //Items which this container can't hold.
+ if(is_type_in_typecache(I, cant_hold) || HAS_TRAIT(I, TRAIT_NO_STORAGE_INSERT) || (can_hold_trait && !HAS_TRAIT(I, can_hold_trait))) //Items which this container can't hold.
if(!stop_messages)
to_chat(M, "[host] cannot hold [I]!")
return FALSE
diff --git a/code/datums/elements/deferred_aquarium_content.dm b/code/datums/elements/deferred_aquarium_content.dm
new file mode 100644
index 00000000000..007dae515a3
--- /dev/null
+++ b/code/datums/elements/deferred_aquarium_content.dm
@@ -0,0 +1,27 @@
+/**
+ * Create /datum/component/aquarium_content with the preset path on the target right before being inserted into aquarium and deletes itself.
+ * Used to save memory from aquarium properties on common objects/stacks that won't see aquarium in 99 out of 100 rounds.
+ */
+/datum/element/deferred_aquarium_content
+ element_flags = ELEMENT_BESPOKE
+ id_arg_index = 2
+ var/aquarium_content_type
+
+/datum/element/deferred_aquarium_content/Attach(datum/target, aquarium_content_type)
+ . = ..()
+ if(!ismovable(target))
+ return ELEMENT_INCOMPATIBLE
+ if(!aquarium_content_type)
+ CRASH("Deferred aquarium content missing behaviour type.")
+ src.aquarium_content_type = aquarium_content_type
+ RegisterSignal(target, COMSIG_AQUARIUM_BEFORE_INSERT_CHECK, .proc/create_aquarium_component)
+
+/datum/element/deferred_aquarium_content/Detach(datum/target)
+ . = ..()
+ UnregisterSignal(target, COMSIG_AQUARIUM_BEFORE_INSERT_CHECK)
+
+/datum/element/deferred_aquarium_content/proc/create_aquarium_component(datum/source)
+ SIGNAL_HANDLER
+
+ source.AddComponent(/datum/component/aquarium_content, aquarium_content_type)
+ Detach(source)
diff --git a/code/datums/mood_events/generic_negative_events.dm b/code/datums/mood_events/generic_negative_events.dm
index c8c62372b1a..50261b04ab6 100644
--- a/code/datums/mood_events/generic_negative_events.dm
+++ b/code/datums/mood_events/generic_negative_events.dm
@@ -305,3 +305,8 @@
description = "OW!! That was even worse than a regular noogie!\n"
mood_change = -4
timeout = 1 MINUTES
+
+/datum/mood_event/aquarium_negative
+ description = "All the fish are dead...\n"
+ mood_change = -3
+ timeout = 1.5 MINUTES
diff --git a/code/datums/mood_events/generic_positive_events.dm b/code/datums/mood_events/generic_positive_events.dm
index c8eaabd7e37..bd65ccf29b3 100644
--- a/code/datums/mood_events/generic_positive_events.dm
+++ b/code/datums/mood_events/generic_positive_events.dm
@@ -218,3 +218,8 @@
description = "HA! What a rube, they never stood a chance...\n"
mood_change = 4
timeout = 1.5 MINUTES
+
+/datum/mood_event/aquarium_positive
+ description = "Watching fish in aquarium is calming.\n"
+ mood_change = 3
+ timeout = 1.5 MINUTES
diff --git a/code/game/objects/items/food/meat.dm b/code/game/objects/items/food/meat.dm
index bac93865ad1..d4cff5baf7c 100644
--- a/code/game/objects/items/food/meat.dm
+++ b/code/game/objects/items/food/meat.dm
@@ -34,6 +34,11 @@
name = "imitation carp fillet"
desc = "Almost just like the real thing, kinda."
+/obj/item/food/carpmeat/icantbeliveitsnotcarp
+ name = "fish fillet"
+ desc = "A fillet of unspecified fish meat."
+ food_reagents = list(/datum/reagent/consumable/nutriment/protein = 4, /datum/reagent/consumable/nutriment/vitamin = 2) //No carpotoxin
+
/obj/item/food/fishfingers
name = "fish fingers"
desc = "A finger of fish."
diff --git a/code/modules/aquarium/aquarium.dm b/code/modules/aquarium/aquarium.dm
new file mode 100644
index 00000000000..6a4e48b64c1
--- /dev/null
+++ b/code/modules/aquarium/aquarium.dm
@@ -0,0 +1,251 @@
+#define AQUARIUM_LAYER_STEP 0.01
+/// Aquarium content layer offsets
+#define AQUARIUM_MIN_OFFSET 0.01
+#define AQUARIUM_MAX_OFFSET 1
+
+/obj/structure/aquarium
+ name = "aquarium"
+ density = TRUE
+ anchored = TRUE
+
+ icon = 'icons/obj/aquarium.dmi'
+ icon_state = "aquarium_base"
+
+ integrity_failure = 0.3
+
+ var/fluid_type = AQUARIUM_FLUID_FRESHWATER
+ var/fluid_temp = MIN_AQUARIUM_TEMP
+ var/min_fluid_temp = MIN_AQUARIUM_TEMP
+ var/max_fluid_temp = MAX_AQUARIUM_TEMP
+ var/lamp = FALSE
+
+ var/glass_icon_state = "aquarium_glass"
+ var/broken_glass_icon_state = "aquarium_glass_broken"
+
+ //This is the area where fish can swim
+ var/aquarium_zone_min_px = 2
+ var/aquarium_zone_max_px = 31
+ var/aquarium_zone_min_py = 10
+ var/aquarium_zone_max_py = 24
+
+ var/list/fluid_types = list(AQUARIUM_FLUID_SALTWATER, AQUARIUM_FLUID_FRESHWATER, AQUARIUM_FLUID_SULPHWATEVER, AQUARIUM_FLUID_AIR)
+
+ var/panel_open = TRUE
+
+ ///Current layers in use by aquarium contents
+ var/list/used_layers = list()
+
+ var/alive_fish = 0
+ var/dead_fish = 0
+
+/obj/structure/aquarium/Initialize()
+ . = ..()
+ update_icon()
+ RegisterSignal(src,COMSIG_PARENT_ATTACKBY, .proc/feed_feedback)
+
+
+/obj/structure/aquarium/proc/request_layer(layer_type)
+ /**
+ * base aq layer
+ * min_offset = this value is returned on bottom layer mode
+ * min_offset + 0.1 fish1
+ * min_offset + 0.2 fish2
+ * ... these layers are returned for auto layer mode and tracked by used_layers
+ * min_offset + max_offset = this value is returned for top layer mode
+ * min_offset + max_offset + 1 = this is used for glass overlay
+ */
+ //optional todo: hook up sending surface changed on aquarium changing layers
+ switch(layer_type)
+ if(AQUARIUM_LAYER_MODE_BOTTOM)
+ return layer + AQUARIUM_MIN_OFFSET
+ if(AQUARIUM_LAYER_MODE_TOP)
+ return layer + AQUARIUM_MAX_OFFSET
+ if(AQUARIUM_LAYER_MODE_AUTO)
+ var/chosen_layer = layer + AQUARIUM_MIN_OFFSET + AQUARIUM_LAYER_STEP
+ while((chosen_layer in used_layers) && (chosen_layer <= layer + AQUARIUM_MAX_OFFSET))
+ chosen_layer += AQUARIUM_LAYER_STEP
+ used_layers += chosen_layer
+ return chosen_layer
+
+/obj/structure/aquarium/proc/free_layer(value)
+ used_layers -= value
+
+/obj/structure/aquarium/proc/get_surface_properties()
+ . = list()
+ .[AQUARIUM_PROPERTIES_PX_MIN] = aquarium_zone_min_px
+ .[AQUARIUM_PROPERTIES_PX_MAX] = aquarium_zone_max_px
+ .[AQUARIUM_PROPERTIES_PY_MIN] = aquarium_zone_min_py
+ .[AQUARIUM_PROPERTIES_PY_MAX] = aquarium_zone_max_py
+
+/obj/structure/aquarium/update_overlays()
+ . = ..()
+ if(panel_open)
+ . += "panel"
+
+ //Glass overlay goes on top of everything else.
+ var/mutable_appearance/glass_overlay = mutable_appearance(icon,broken ? broken_glass_icon_state : glass_icon_state,layer=AQUARIUM_MAX_OFFSET-1)
+ . += glass_overlay
+
+/obj/structure/aquarium/examine(mob/user)
+ . = ..()
+ . += "Alt-click to [panel_open ? "close" : "open"] the control panel."
+
+/obj/structure/aquarium/AltClick(mob/user)
+ if(!user.canUseTopic(src, BE_CLOSE))
+ return ..()
+ panel_open = !panel_open
+ update_icon()
+
+/obj/structure/aquarium/wrench_act(mob/living/user, obj/item/I)
+ if(default_unfasten_wrench(user,I))
+ return TRUE
+
+/obj/structure/aquarium/attackby(obj/item/I, mob/living/user, params)
+ if(broken)
+ var/obj/item/stack/sheet/glass/glass = I
+ if(istype(glass))
+ if(glass.get_amount() < 2)
+ to_chat(user, "You need two glass sheets to fix the case!")
+ return
+ to_chat(user, "You start fixing [src]...")
+ if(do_after(user, 2 SECONDS, target = src))
+ glass.use(2)
+ broken = FALSE
+ obj_integrity = max_integrity
+ update_icon()
+ return TRUE
+ else
+ // This signal exists so we common items instead of adding component on init can just register creation of one in response.
+ // This way we can avoid the cost of 9999 aquarium components on rocks that will never see water in their life.
+ SEND_SIGNAL(I,COMSIG_AQUARIUM_BEFORE_INSERT_CHECK,src)
+ var/datum/component/aquarium_content/content_component = I.GetComponent(/datum/component/aquarium_content)
+ if(content_component && content_component.is_ready_to_insert(src))
+ if(user.transferItemToLoc(I,src))
+ update_icon()
+ return TRUE
+ else
+ return ..()
+ return ..()
+
+/obj/structure/aquarium/proc/feed_feedback(datum/source, obj/item/thing, mob/user, params)
+ SIGNAL_HANDLER
+ if(istype(thing, /obj/item/fish_feed))
+ to_chat(user,"You feed the fish.")
+ return NONE
+
+/obj/structure/aquarium/interact(mob/user)
+ if(!broken && user.pulling && user.a_intent == INTENT_GRAB && isliving(user.pulling))
+ var/mob/living/living_pulled = user.pulling
+ SEND_SIGNAL(living_pulled, COMSIG_AQUARIUM_BEFORE_INSERT_CHECK,src)
+ var/datum/component/aquarium_content/content_component = living_pulled.GetComponent(/datum/component/aquarium_content)
+ if(content_component && content_component.is_ready_to_insert(src))
+ try_to_put_mob_in(user)
+ else if(panel_open)
+ . = ..() //call base ui_interact
+ else
+ admire(user)
+
+/// Tries to put mob pulled by the user in the aquarium after a delay
+/obj/structure/aquarium/proc/try_to_put_mob_in(mob/user)
+ if(user.pulling && user.a_intent == INTENT_GRAB && isliving(user.pulling))
+ var/mob/living/living_pulled = user.pulling
+ if(living_pulled.buckled || living_pulled.has_buckled_mobs())
+ to_chat(user, "[living_pulled] is attached to something!")
+ return
+ user.visible_message("[user] starts to put [living_pulled] into [src]!")
+ if(do_after(user, 10 SECONDS, target = src))
+ if(QDELETED(living_pulled) || user.pulling != living_pulled || living_pulled.buckled || living_pulled.has_buckled_mobs())
+ return
+ var/datum/component/aquarium_content/content_component = living_pulled.GetComponent(/datum/component/aquarium_content)
+ if(content_component || content_component.is_ready_to_insert(src))
+ return
+ user.visible_message("[user] stuffs [living_pulled] into [src]!")
+ living_pulled.forceMove(src)
+ update_icon()
+
+///Apply mood bonus depending on aquarium status
+/obj/structure/aquarium/proc/admire(mob/user)
+ to_chat(user,"You take a moment to watch [src].")
+ if(do_after(user, 5 SECONDS, target = src))
+ //Check if there are live fish - good mood
+ //All fish dead - bad mood.
+ //No fish - nothing.
+ if(alive_fish > 0)
+ SEND_SIGNAL(user, COMSIG_ADD_MOOD_EVENT, "aquarium", /datum/mood_event/aquarium_positive)
+ else if(dead_fish > 0)
+ SEND_SIGNAL(user, COMSIG_ADD_MOOD_EVENT, "aquarium", /datum/mood_event/aquarium_negative)
+ // Could maybe scale power of this mood with number/types of fish
+
+/obj/structure/aquarium/ui_data(mob/user)
+ . = ..()
+ .["fluid_type"] = fluid_type
+ .["temperature"] = fluid_temp
+ var/list/content_data = list()
+ for(var/atom/movable/fish in contents)
+ content_data += list(list("name"=fish.name,"ref"=ref(fish)))
+ .["contents"] = content_data
+
+/obj/structure/aquarium/ui_static_data(mob/user)
+ . = ..()
+ //I guess these should depend on the fluid so lava critters can get high or stuff below water freezing point but let's keep it simple for now.
+ .["minTemperature"] = min_fluid_temp
+ .["maxTemperature"] = max_fluid_temp
+ .["fluidTypes"] = fluid_types
+
+/obj/structure/aquarium/ui_act(action, params)
+ . = ..()
+ if(.)
+ return
+ var/mob/user = usr
+ switch(action)
+ if("temperature")
+ var/temperature = params["temperature"]
+ if(isnum(temperature))
+ fluid_temp = clamp(temperature, min_fluid_temp, max_fluid_temp)
+ . = TRUE
+ if("fluid")
+ if(params["fluid"] in fluid_types)
+ fluid_type = params["fluid"]
+ SEND_SIGNAL(src, COMSIG_AQUARIUM_FLUID_CHANGED, fluid_type)
+ . = TRUE
+ if("remove")
+ var/atom/movable/inside = locate(params["ref"]) in contents
+ if(inside)
+ if(isitem(inside))
+ user.put_in_hands(inside)
+ else
+ inside.forceMove(get_turf(src))
+ to_chat(user,"You take out [inside] from [src].")
+
+/obj/structure/aquarium/ui_interact(mob/user, datum/tgui/ui)
+ . = ..()
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "Aquarium", name)
+ ui.open()
+
+/obj/structure/aquarium/obj_break(damage_flag)
+ . = ..()
+ if(!broken)
+ aquarium_smash()
+
+/obj/structure/aquarium/proc/aquarium_smash()
+ broken = TRUE
+ var/possible_destinations_for_fish = list()
+ var/droploc = drop_location()
+ if(isturf(droploc))
+ possible_destinations_for_fish = get_adjacent_open_turfs(droploc)
+ else
+ possible_destinations_for_fish = list(droploc)
+ playsound(src, 'sound/effects/glassbr3.ogg', 100, TRUE)
+ for(var/atom/movable/fish in contents)
+ fish.forceMove(pick(possible_destinations_for_fish))
+ if(fluid_type != AQUARIUM_FLUID_AIR)
+ var/datum/reagents/reagent_splash = new()
+ reagent_splash.add_reagent(/datum/reagent/water, 30)
+ chem_splash(droploc, 3, list(reagent_splash))
+ update_icon()
+
+#undef AQUARIUM_LAYER_STEP
+#undef AQUARIUM_MIN_OFFSET
+#undef AQUARIUM_MAX_OFFSET
diff --git a/code/modules/aquarium/aquarium_behaviour.dm b/code/modules/aquarium/aquarium_behaviour.dm
new file mode 100644
index 00000000000..eadf21d07f2
--- /dev/null
+++ b/code/modules/aquarium/aquarium_behaviour.dm
@@ -0,0 +1,259 @@
+// Configuration objects defining prop/fish behaviour in the aquarium
+// These are used as a base for autogenerating the actual instances
+/datum/aquarium_behaviour
+ var/name = "base aquarium element"
+ var/desc = ""
+
+ var/unique = FALSE
+
+ var/icon = 'icons/obj/aquarium.dmi'
+ /// Icon state used for catalog/autogenerated fish item. Also used as basis for in aquarium visual if dedicated_in_aquarium_icon_state is not set.
+ var/icon_state
+ /// Applied and item/catalog for use with greyscaled icons.
+ var/color
+
+ /// How the thing will be layered
+ var/layer_mode = AQUARIUM_LAYER_MODE_AUTO
+ /// If the starting position is randomised within bounds.
+ var/randomize_position = FALSE
+
+ /**
+ * Fish sprite how to:
+ * Need to be centered on 16,16 in the dmi and facing left by default.
+ * sprite_height/sprite_width is the size it will have in aquarium and used to control animation boundaries.
+ * source_height/source_width is the size of the original icon (ideally only the non-empty parts)
+ */
+
+ /// If this is set this icon state will be used for the holder while icon_state will only be used for item/catalog. Transformation from source_width/height WON'T be applied.
+ var/dedicated_in_aquarium_icon_state
+ /// Applied to vc object only for use with greyscaled icons.
+ var/aquarium_vc_color
+
+ //Target sprite size for path/position calculations.
+ var/sprite_height = 3
+ var/sprite_width = 3
+ //This is the size of the source sprite. This will be used to calculate scale down factor.
+ var/source_width = 32
+ var/source_height = 32
+
+ /// Current animation
+ var/current_animation
+
+ /// Does this behviour need additional processing in aquarium, will be added to SSobj processing on insertion
+ var/processing = FALSE
+
+ /// Our holder component
+ var/datum/component/aquarium_content/parent
+
+/// Applies icon,color and base scaling to our visual holder
+/datum/aquarium_behaviour/proc/apply_appearance(obj/effect/holder)
+ holder.icon = icon
+ if(dedicated_in_aquarium_icon_state)
+ holder.icon_state = dedicated_in_aquarium_icon_state
+ else
+ holder.icon_state = icon_state
+ holder.transform = base_transform()
+ if(aquarium_vc_color)
+ holder.color = aquarium_vc_color
+
+// Generates scaling matrix for our visual
+/datum/aquarium_behaviour/proc/base_transform()
+ var/matrix/matrix = matrix()
+ if(!dedicated_in_aquarium_icon_state)
+ var/x_scale = sprite_width / source_width
+ var/y_scale = sprite_height / source_height
+ matrix.Scale(x_scale, y_scale)
+ return matrix
+
+/// Called when fed.
+/datum/aquarium_behaviour/proc/on_feeding(datum/reagents/feed_reagents)
+ return
+
+/// Called when aquarium fluid changes.
+/datum/aquarium_behaviour/proc/on_fluid_changed()
+ return
+
+/// Called to update current animation based on state
+/datum/aquarium_behaviour/proc/update_animation()
+ return
+
+/// Called when inserted into new aquarium
+/datum/aquarium_behaviour/proc/on_inserted()
+ if(processing)
+ START_PROCESSING(SSobj, src)
+
+/// Called just before the object gets removed from the aquarium
+/datum/aquarium_behaviour/proc/before_removal()
+ return
+
+/datum/aquarium_behaviour/Destroy(force, ...)
+ STOP_PROCESSING(SSobj, src)
+ parent = null
+ return ..()
+
+/datum/aquarium_behaviour/prop
+ name = "aquarium prop"
+ unique = TRUE
+ layer_mode = AQUARIUM_LAYER_MODE_BOTTOM
+
+ // Prop sprites do not need any scaling they go 1:1
+ sprite_width = 32
+ sprite_height = 32
+ source_width = 32
+ source_height = 32
+
+/datum/aquarium_behaviour/prop/rocks
+ name = "rocks"
+ icon_state = "rocks"
+
+/datum/aquarium_behaviour/prop/seaweed_top
+ name = "dense seaweeds"
+ icon_state = "seaweeds_front"
+ layer_mode = AQUARIUM_LAYER_MODE_TOP
+
+/datum/aquarium_behaviour/prop/seaweed
+ name = "seaweeds"
+ icon_state = "seaweeds_back"
+ layer_mode = AQUARIUM_LAYER_MODE_BOTTOM
+
+/datum/aquarium_behaviour/prop/rockfloor
+ name = "rock floor"
+ icon_state = "rockfloor"
+ layer_mode = AQUARIUM_LAYER_MODE_BOTTOM
+
+/datum/aquarium_behaviour/prop/treasure
+ name = "tiny treasure chest"
+ icon_state = "treasure"
+ layer_mode = AQUARIUM_LAYER_MODE_BOTTOM
+
+/datum/aquarium_behaviour/fish
+ name = "generic fish"
+ icon_state = "fish_greyscale"
+ randomize_position = TRUE //We have random starting point of our swim path
+
+ current_animation = AQUARIUM_ANIMATION_FISH_SWIM
+ processing = TRUE
+
+ // Default size of the "greyscale_fish" icon_state
+ sprite_height = 3
+ sprite_width = 3
+
+ /// Required fluid type for this fish to live.
+ var/required_fluid_type = AQUARIUM_FLUID_FRESHWATER
+
+ /// Required minimum temperature for the fish to live.
+ var/required_temperature_min = MIN_AQUARIUM_TEMP
+ /// Maximum possible temperature for the fish to live.
+ var/required_temperature_max = MAX_AQUARIUM_TEMP
+
+ /// What type of reagent this fish needs to be fed.
+ var/food = /datum/reagent/consumable/nutriment
+ /// How often the fish needs to be fed
+ var/feeding_frequency = 5 MINUTES
+ /// Time of last feedeing
+ var/last_feeding
+
+ /// Fish status
+ var/status = FISH_ALIVE
+
+ /// Current fish health. Dies at 0.
+ var/health = 100
+
+ /// Should this fish type show in fish catalog
+ var/show_in_catalog = TRUE
+ /// Should this fish spawn in random fish cases
+ var/availible_in_random_cases = TRUE
+ /// How rare this fish is in the random cases
+ var/random_case_rarity = FISH_RARITY_BASIC
+
+ /// Fish autogenerated from this behaviour will be processable into this
+ var/fillet_type = /obj/item/food/carpmeat/icantbeliveitsnotcarp
+
+
+/datum/aquarium_behaviour/fish/on_inserted()
+ . = ..()
+ if(isnull(last_feeding)) //Fish start fed.
+ last_feeding = world.time
+ if(status == FISH_DEAD)
+ parent.current_aquarium.dead_fish += 1
+ else
+ parent.current_aquarium.alive_fish += 1
+
+/datum/aquarium_behaviour/fish/before_removal()
+ . = ..()
+ if(status == FISH_DEAD)
+ parent.current_aquarium.dead_fish -= 1
+ else
+ parent.current_aquarium.alive_fish -= 1
+
+/datum/aquarium_behaviour/fish/on_fluid_changed()
+ //In case we'll flop to bottom from this or go back to swimming.
+ update_animation()
+
+/// Updates our animation variable based on state and prompts component to animate it.
+/datum/aquarium_behaviour/fish/update_animation()
+ var/obj/structure/aquarium/aquarium = parent.current_aquarium
+ var/old_animation = current_animation
+ if(!aquarium || aquarium.fluid_type == AQUARIUM_FLUID_AIR || status == FISH_DEAD)
+ current_animation = AQUARIUM_ANIMATION_FISH_DEAD
+ else
+ current_animation = AQUARIUM_ANIMATION_FISH_SWIM
+ if(aquarium && old_animation != current_animation)
+ parent.generate_animation()
+
+/datum/aquarium_behaviour/fish/on_feeding(datum/reagents/feed_reagents)
+ if(feed_reagents.has_reagent(food))
+ last_feeding = world.time
+
+/// Checks if our current environment lets us live.
+/datum/aquarium_behaviour/fish/proc/proper_environment()
+ var/obj/structure/aquarium/aquarium = parent.current_aquarium
+ if(!aquarium)
+ return FALSE
+ if(aquarium.fluid_type != required_fluid_type)
+ return FALSE
+ if(aquarium.fluid_temp < required_temperature_min || aquarium.fluid_temp > required_temperature_max)
+ return FALSE
+ return TRUE
+
+/datum/aquarium_behaviour/fish/process(delta_time)
+ set waitfor = FALSE
+
+ var/health_change_per_second = 0
+ if(!proper_environment())
+ health_change_per_second -= 3 //Dying here
+ if(world.time - last_feeding >= feeding_frequency)
+ health_change_per_second -= 0.5 //Starving
+ else
+ health_change_per_second += 0.5 //Slowly healing
+ health = clamp(health + health_change_per_second * delta_time, 0, initial(health))
+ if(health <= 0)
+ death()
+
+/datum/aquarium_behaviour/fish/proc/death()
+ STOP_PROCESSING(SSobj, src)
+ status = FISH_DEAD
+ var/message = "\The [name] dies."
+ if(parent.current_aquarium)
+ parent.current_aquarium.visible_message(message)
+ parent.current_aquarium.alive_fish -= 1
+ parent.current_aquarium.dead_fish += 1
+ else
+ var/atom/movable/AM = parent.parent
+ AM.visible_message(message)
+ update_animation()
+
+
+/// This path exists mostly for admin abuse.
+/datum/aquarium_behaviour/fish/auto
+ name = "automatic fish"
+ desc = "generates fish appearance automatically from component parent appearance"
+ availible_in_random_cases = FALSE
+ sprite_width = 8
+ sprite_height = 8
+ show_in_catalog = FALSE
+
+/datum/aquarium_behaviour/fish/auto/apply_appearance(obj/effect/holder)
+ holder.appearance = parent.parent
+ holder.transform = base_transform()
+ holder.dir = WEST
diff --git a/code/modules/aquarium/fish.dm b/code/modules/aquarium/fish.dm
new file mode 100644
index 00000000000..b37e7ff30e7
--- /dev/null
+++ b/code/modules/aquarium/fish.dm
@@ -0,0 +1,143 @@
+// Fish path used for autogenerated fish
+/obj/item/fish
+ name = "generic looking aquarium fish"
+ desc = "very bland"
+ w_class = WEIGHT_CLASS_TINY
+
+/// Automatically generates object of given base path from the behaviour type in loc
+/proc/generate_fish(loc,behaviour_type,base_path=/obj/item/fish)
+ var/datum/aquarium_behaviour/behaviour = behaviour_type
+ var/obj/item/fish = new /obj/item/fish(loc)
+ fish.name = initial(behaviour.name)
+ fish.icon = initial(behaviour.icon)
+ fish.icon_state = initial(behaviour.icon_state)
+ fish.desc = initial(behaviour.desc)
+ if(initial(behaviour.color))
+ fish.add_atom_colour(initial(behaviour.color), FIXED_COLOUR_PRIORITY)
+ if(ispath(behaviour_type,/datum/aquarium_behaviour/fish))
+ var/datum/aquarium_behaviour/fish/fish_behaviour = behaviour_type
+ var/fillet_type = initial(fish_behaviour.fillet_type)
+ if(fillet_type)
+ fish.AddElement(/datum/element/processable, TOOL_KNIFE, fillet_type, 1, 5)
+ fish.AddElement(/datum/element/deferred_aquarium_content, behaviour_type)
+ return fish
+
+/// Returns random fish, using random_case_rarity probabilities.
+/proc/random_fish_type(case_fish_only=TRUE, required_fluid)
+ var/static/probability_table
+ var/argkey = "fish_[required_fluid]_[case_fish_only]" //If this expands more extract bespoke element arg generation to some common helper.
+ if(!probability_table || !probability_table[argkey])
+ if(!probability_table)
+ probability_table = list()
+ var/chance_table = list()
+ for(var/_fish_behavior in subtypesof(/datum/aquarium_behaviour/fish))
+ var/datum/aquarium_behaviour/fish/fish_behavior = _fish_behavior
+ if(required_fluid && initial(fish_behavior.required_fluid_type) != required_fluid)
+ continue
+ if(initial(fish_behavior.availible_in_random_cases) || !case_fish_only)
+ chance_table[fish_behavior] = initial(fish_behavior.random_case_rarity)
+ probability_table[argkey] = chance_table
+ return pickweight(probability_table[argkey])
+
+// Actual fish definitions below - there's no specific paths, they are autogenerated from behaviours
+
+// Freshwater fish
+
+/datum/aquarium_behaviour/fish/goldfish
+ name = "goldfish"
+ desc = "Despite common belief, goldfish do not have three-second memories. They can actually remember things that happened up to three months ago."
+ icon_state = "goldfish"
+ sprite_width = 8
+ sprite_height = 8
+
+/datum/aquarium_behaviour/fish/angelfish
+ name = "angelfish"
+ desc = "Young Angelfish often live in groups, while adults prefer solitary life. They become territorial and aggressive toward other fish when they reach adulthood."
+ icon_state = "angelfish"
+ dedicated_in_aquarium_icon_state = "bigfish"
+ sprite_height = 7
+ source_height = 7
+
+/datum/aquarium_behaviour/fish/guppy
+ name = "guppy"
+ desc = "Guppy is also known as rainbow fish because of the brightly colored body and fins."
+ icon_state = "guppy"
+ dedicated_in_aquarium_icon_state = "fish_greyscale"
+ aquarium_vc_color = "#91AE64"
+ sprite_width = 8
+ sprite_height = 5
+
+/datum/aquarium_behaviour/fish/plasmatetra
+ name = "plasma tetra"
+ desc = "Due to their small size, tetras are prey to many predators in their watery world, including eels, crustaceans, and invertebrates."
+ icon_state = "plastetra"
+ dedicated_in_aquarium_icon_state = "fish_greyscale"
+ aquarium_vc_color = "#D30EB0"
+
+/datum/aquarium_behaviour/fish/catfish
+ name = "cory catfish"
+ desc = "A catfish has about 100,000 taste buds, and their bodies are covered with them to help detect chemicals present in the water and also to respond to touch."
+ icon_state = "catfish"
+ dedicated_in_aquarium_icon_state = "fish_greyscale"
+ aquarium_vc_color = "#907420"
+
+/datum/aquarium_behaviour/fish/spacecarp
+ name = "space carp"
+ desc = "This space predator fish is known to cause yearly damage to space stations during their migrations."
+ icon_state = "carp"
+ sprite_width = 8
+ sprite_height = 8
+ availible_in_random_cases = FALSE
+
+// Saltwater fish below
+
+/datum/aquarium_behaviour/fish/clownfish
+ name = "clownfish"
+ desc = "Clownfish catch prey by swimming onto the reef, attracting larger fish, and luring them back to the anemone. The anemone will sting and eat the larger fish, leaving the remains for the clownfish."
+ icon_state = "clownfish"
+ dedicated_in_aquarium_icon_state = "clownfish_small"
+ required_fluid_type = AQUARIUM_FLUID_SALTWATER
+ sprite_width = 8
+ sprite_height = 5
+
+/datum/aquarium_behaviour/fish/cardinal
+ name = "cardinalfish"
+ desc = "Cardinalfish are often found near sea urchins, where the fish hide when threatened."
+ icon_state = "cardinalfish"
+ dedicated_in_aquarium_icon_state = "fish_greyscale"
+ required_fluid_type = AQUARIUM_FLUID_SALTWATER
+
+/datum/aquarium_behaviour/fish/greenchromis
+ name = "green chromis"
+ desc = "The Chromis can vary in color from blue to green depending on the lighting and distance from the lights."
+ icon_state = "greenchromis"
+ dedicated_in_aquarium_icon_state = "fish_greyscale"
+ aquarium_vc_color = "#00ff00"
+ required_fluid_type = AQUARIUM_FLUID_SALTWATER
+
+/datum/aquarium_behaviour/fish/firefish
+ name = "firefish goby"
+ desc = "To communicate in the wild, the firefish uses its dorsal fin to alert others of potential danger."
+ icon_state = "firefish"
+ sprite_width = 6
+ sprite_height = 5
+ required_fluid_type = AQUARIUM_FLUID_SALTWATER
+
+/datum/aquarium_behaviour/fish/pufferfish
+ name = "pufferfish"
+ desc = "One Pufferfish contains enough toxins in its liver to kill 30 people."
+ icon_state = "pufferfish"
+ required_fluid_type = AQUARIUM_FLUID_SALTWATER
+ sprite_width = 8
+ sprite_height = 8
+
+/datum/aquarium_behaviour/fish/lanternfish
+ name = "lanternfish"
+ desc = "Typically found in areas below 6600 feet below the surface of the ocean, they live in complete darkness."
+ icon_state = "lanternfish"
+ required_fluid_type = AQUARIUM_FLUID_SALTWATER
+ random_case_rarity = FISH_RARITY_VERY_RARE
+ source_width = 28
+ source_height = 21
+ sprite_width = 8
+ sprite_height = 8
diff --git a/code/modules/aquarium/misc.dm b/code/modules/aquarium/misc.dm
new file mode 100644
index 00000000000..cde04bf2935
--- /dev/null
+++ b/code/modules/aquarium/misc.dm
@@ -0,0 +1,125 @@
+#define AQUARIUM_COMPANY "Aquatech Ltd."
+
+// Fish feed can
+/obj/item/fish_feed
+ name = "fish feed can"
+ desc = "Autogenerates nutritious fish feed based on sample inside."
+ icon = 'icons/obj/aquarium.dmi'
+ icon_state = "fish_feed"
+ w_class = WEIGHT_CLASS_TINY
+
+/obj/item/fish_feed/Initialize()
+ . = ..()
+ create_reagents(5, OPENCONTAINER)
+ reagents.add_reagent(/datum/reagent/consumable/nutriment, 1) //Default fish diet
+
+// Stasis fish case container for moving fish between aquariums safely.
+/obj/item/storage/fish_case
+ name = "stasis fish case"
+ desc = "A small case keeping the fish inside in stasis."
+ icon_state = "fishbox"
+
+ inhand_icon_state = "syringe_kit"
+ lefthand_file = 'icons/mob/inhands/equipment/medical_lefthand.dmi'
+ righthand_file = 'icons/mob/inhands/equipment/medical_righthand.dmi'
+
+ component_type = /datum/component/storage/concrete/fish_case
+
+/obj/item/storage/fish_case/Initialize()
+ . = ..()
+ ADD_TRAIT(src, TRAIT_FISH_SAFE_STORAGE, TRAIT_GENERIC)
+
+/// Fish case with single random fish inside.
+/obj/item/storage/fish_case/random/PopulateContents()
+ . = ..()
+ generate_fish(src, select_fish_type())
+
+/obj/item/storage/fish_case/random/proc/select_fish_type()
+ return random_fish_type()
+
+/obj/item/storage/fish_case/random/freshwater/select_fish_type()
+ return random_fish_type(required_fluid=AQUARIUM_FLUID_FRESHWATER)
+
+/// Book detailing where to get the fish and their properties.
+/obj/item/book/fish_catalog
+ name = "Fish Encyclopedia"
+ desc = "Indexes all fish known to mankind (and related species)"
+ icon_state = "fishbook"
+ dat = "Lot of fish stuff" //book wrappers could use cleaning so this is not necessary
+
+/obj/item/book/fish_catalog/on_read(mob/user)
+ ui_interact(user)
+
+/obj/item/book/fish_catalog/ui_interact(mob/user, datum/tgui/ui)
+ . = ..()
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "FishCatalog", name)
+ ui.open()
+
+/obj/item/book/fish_catalog/ui_static_data(mob/user)
+ . = ..()
+ var/static/fish_info
+ if(!fish_info)
+ fish_info = list()
+ for(var/_fish_behaviour in subtypesof(/datum/aquarium_behaviour/fish))
+ var/datum/aquarium_behaviour/fish/fish_behaviour = _fish_behaviour
+ var/list/fish_data = list()
+ if(!initial(fish_behaviour.show_in_catalog))
+ continue
+ fish_data["name"] = initial(fish_behaviour.name)
+ fish_data["desc"] = initial(fish_behaviour.desc)
+ fish_data["fluid"] = initial(fish_behaviour.required_fluid_type)
+ fish_data["temp_min"] = initial(fish_behaviour.required_temperature_min)
+ fish_data["temp_max"] = initial(fish_behaviour.required_temperature_max)
+ fish_data["icon"] = sanitize_css_class_name("[initial(fish_behaviour.icon)][initial(fish_behaviour.icon_state)]")
+ fish_data["color"] = initial(fish_behaviour.color)
+ fish_data["source"] = initial(fish_behaviour.availible_in_random_cases) ? "[AQUARIUM_COMPANY] Fish Packs" : "Unknown"
+ var/datum/reagent/food_type = initial(fish_behaviour.food)
+ if(food_type != /datum/reagent/consumable/nutriment)
+ fish_data["feed"] = initial(food_type.name)
+ else
+ fish_data["feed"] = "[AQUARIUM_COMPANY] Fish Feed"
+ fish_info += list(fish_data)
+
+ .["fish_info"] = fish_info
+ .["sponsored_by"] = AQUARIUM_COMPANY
+
+/obj/item/book/fish_catalog/ui_assets(mob/user)
+ return list(
+ get_asset_datum(/datum/asset/spritesheet/fish)
+ )
+
+/obj/item/aquarium_kit
+ name = "DIY Aquarium Construction Kit"
+ desc = "Everything you need to build your own aquarium.(Raw materials sold separately)"
+ icon = 'icons/obj/aquarium.dmi'
+ icon_state = "construction_kit"
+ w_class = WEIGHT_CLASS_TINY
+
+/obj/item/aquarium_kit/attack_self(mob/user)
+ . = ..()
+ to_chat(user,"There's instruction and tools necessary to build aquarium inside. All you need is to start crafting.")
+
+
+/obj/item/aquarium_prop
+ name = "generic aquarium prop"
+ desc = "very boring"
+ w_class = WEIGHT_CLASS_TINY
+
+/obj/item/storage/box/aquarium_props
+ name = "aquarium props box"
+ desc = "All you need to make your aquarium look good"
+
+/obj/item/storage/box/aquarium_props/PopulateContents()
+ for(var/prop_type in subtypesof(/datum/aquarium_behaviour/prop))
+ generate_fish(src, prop_type, /obj/item/aquarium_prop)
+
+/obj/item/storage/box/fish_debug
+ name = "box full of fish"
+
+/obj/item/storage/box/fish_debug/PopulateContents()
+ for(var/fish_type in subtypesof(/datum/aquarium_behaviour/fish))
+ generate_fish(src,fish_type)
+
+#undef AQUARIUM_COMPANY
diff --git a/code/modules/asset_cache/asset_list_items.dm b/code/modules/asset_cache/asset_list_items.dm
index 29f7d87c080..9c67d148af5 100644
--- a/code/modules/asset_cache/asset_list_items.dm
+++ b/code/modules/asset_cache/asset_list_items.dm
@@ -516,3 +516,22 @@
assets = list(
"safe_dial.png" = 'html/safe_dial.png'
)
+
+/datum/asset/spritesheet/fish
+ name = "fish"
+
+/datum/asset/spritesheet/fish/register()
+ for (var/path in subtypesof(/datum/aquarium_behaviour/fish))
+ var/datum/aquarium_behaviour/fish/fish_type = path
+ var/fish_icon = initial(fish_type.icon)
+ var/fish_icon_state = initial(fish_type.icon_state)
+ var/id = sanitize_css_class_name("[fish_icon][fish_icon_state]")
+ if(sprites[id]) //no dupes
+ continue
+ Insert(id, fish_icon, fish_icon_state)
+ ..()
+
+/// Removes all non-alphanumerics from the text, keep in mind this can lead to id conflicts
+/proc/sanitize_css_class_name(name)
+ var/static/regex/regex = new(@"[^a-zA-Z0-9]","g")
+ return replacetext(name, regex, "")
diff --git a/code/modules/cargo/packs.dm b/code/modules/cargo/packs.dm
index 1bf49c68d9a..7c87ad27a8b 100644
--- a/code/modules/cargo/packs.dm
+++ b/code/modules/cargo/packs.dm
@@ -2373,6 +2373,27 @@
crate_name = "art supply crate"
crate_type = /obj/structure/closet/crate/wooden
+/datum/supply_pack/misc/aquarium_kit
+ name = "Aquarium Kit"
+ desc = "Everything you need to start your own aquarium. Contains aquarium construction kit, fish catalog, feed can and three freshwater fish from our collection."
+ cost = CARGO_CRATE_VALUE * 10
+ contains = list(/obj/item/book/fish_catalog,
+ /obj/item/storage/fish_case/random/freshwater,
+ /obj/item/storage/fish_case/random/freshwater,
+ /obj/item/storage/fish_case/random/freshwater,
+ /obj/item/fish_feed,
+ /obj/item/storage/box/aquarium_props,
+ /obj/item/aquarium_kit)
+ crate_name = "aquarium kit crate"
+ crate_type = /obj/structure/closet/crate/wooden
+
+/datum/supply_pack/misc/aquarium_fish
+ name = "Aquarium Fish Case"
+ desc = "An aquarium fish handpicked by monkeys from our collection."
+ cost = CARGO_CRATE_VALUE * 2
+ contains = list(/obj/item/storage/fish_case/random)
+ crate_name = "aquarium fish crate"
+
/datum/supply_pack/misc/bicycle
name = "Bicycle"
desc = "Nanotrasen reminds all employees to never toy with powers outside their control."
diff --git a/code/modules/library/lib_items.dm b/code/modules/library/lib_items.dm
index f92bf1c2fbb..2a797148b14 100644
--- a/code/modules/library/lib_items.dm
+++ b/code/modules/library/lib_items.dm
@@ -225,10 +225,13 @@
/obj/item/book/attack_self(mob/user)
if(!user.can_read(src))
return
+ user.visible_message("[user] opens a book titled \"[title]\" and begins reading intently.")
+ SEND_SIGNAL(user, COMSIG_ADD_MOOD_EVENT, "book_nerd", /datum/mood_event/book_nerd)
+ on_read(user)
+
+/obj/item/book/proc/on_read(mob/user)
if(dat)
user << browse("Penned by [author].
" + "[dat]", "window=book[window_size != null ? ";size=[window_size]" : ""]")
- user.visible_message("[user] opens a book titled \"[title]\" and begins reading intently.")
- SEND_SIGNAL(user, COMSIG_ADD_MOOD_EVENT, "book_nerd", /datum/mood_event/book_nerd)
onclose(user, "book")
else
to_chat(user, "This book is completely blank!")
diff --git a/icons/obj/aquarium.dmi b/icons/obj/aquarium.dmi
new file mode 100644
index 00000000000..025967ac6ee
Binary files /dev/null and b/icons/obj/aquarium.dmi differ
diff --git a/icons/obj/library.dmi b/icons/obj/library.dmi
index 3944513c599..50063c0fd6d 100644
Binary files a/icons/obj/library.dmi and b/icons/obj/library.dmi differ
diff --git a/icons/obj/storage.dmi b/icons/obj/storage.dmi
index 5802bda9086..5b34ac4773a 100644
Binary files a/icons/obj/storage.dmi and b/icons/obj/storage.dmi differ
diff --git a/tgstation.dme b/tgstation.dme
index 65ec630e4aa..7f648f5648a 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -28,6 +28,7 @@
#include "code\__DEFINES\admin.dm"
#include "code\__DEFINES\ai.dm"
#include "code\__DEFINES\antagonists.dm"
+#include "code\__DEFINES\aquarium.dm"
#include "code\__DEFINES\atmospherics.dm"
#include "code\__DEFINES\atom_hud.dm"
#include "code\__DEFINES\bitfields.dm"
@@ -441,6 +442,7 @@
#include "code\datums\components\_component.dm"
#include "code\datums\components\acid.dm"
#include "code\datums\components\anti_magic.dm"
+#include "code\datums\components\aquarium.dm"
#include "code\datums\components\areabound.dm"
#include "code\datums\components\armor_plate.dm"
#include "code\datums\components\bane.dm"
@@ -544,6 +546,7 @@
#include "code\datums\components\storage\concrete\_concrete.dm"
#include "code\datums\components\storage\concrete\bag_of_holding.dm"
#include "code\datums\components\storage\concrete\bluespace.dm"
+#include "code\datums\components\storage\concrete\fish_case.dm"
#include "code\datums\components\storage\concrete\implant.dm"
#include "code\datums\components\storage\concrete\pockets.dm"
#include "code\datums\components\storage\concrete\rped.dm"
@@ -617,6 +620,7 @@
#include "code\datums\elements\caltrop.dm"
#include "code\datums\elements\cleaning.dm"
#include "code\datums\elements\climbable.dm"
+#include "code\datums\elements\deferred_aquarium_content.dm"
#include "code\datums\elements\digitalcamo.dm"
#include "code\datums\elements\dryable.dm"
#include "code\datums\elements\earhealing.dm"
@@ -1676,6 +1680,10 @@
#include "code\modules\antagonists\wizard\equipment\soulstone.dm"
#include "code\modules\antagonists\wizard\equipment\spellbook.dm"
#include "code\modules\antagonists\xeno\xeno.dm"
+#include "code\modules\aquarium\aquarium.dm"
+#include "code\modules\aquarium\aquarium_behaviour.dm"
+#include "code\modules\aquarium\fish.dm"
+#include "code\modules\aquarium\misc.dm"
#include "code\modules\assembly\assembly.dm"
#include "code\modules\assembly\bomb.dm"
#include "code\modules\assembly\doorcontrol.dm"
diff --git a/tgui/packages/tgui/interfaces/Aquarium.js b/tgui/packages/tgui/interfaces/Aquarium.js
new file mode 100644
index 00000000000..87b8ca68dce
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Aquarium.js
@@ -0,0 +1,59 @@
+import { useBackend } from '../backend';
+import { Button, Dropdown, Knob, LabeledControls, Section } from '../components';
+import { Window } from '../layouts';
+
+export const Aquarium = (props, context) => {
+ const { act, data } = useBackend(context);
+ const {
+ temperature,
+ fluid_type,
+ minTemperature,
+ maxTemperature,
+ fluidTypes,
+ contents,
+ } = data;
+ return (
+
+
+
+
+
+ act('temperature', {
+ temperature: value,
+ })} />
+
+
+ {fluidTypes.map(f => (
+
+
+
+
+ {contents.map(movable => (
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/FishCatalog.js b/tgui/packages/tgui/interfaces/FishCatalog.js
new file mode 100644
index 00000000000..51226bf3bc3
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/FishCatalog.js
@@ -0,0 +1,82 @@
+import { sortBy } from 'common/collections';
+import { flow } from 'common/fp';
+import { classes } from 'common/react';
+import { useBackend, useLocalState } from '../backend';
+import { Box, Button, LabeledList, Section, Stack } from '../components';
+import { Window } from '../layouts';
+import { capitalize } from 'common/string';
+
+export const FishCatalog = (props, context) => {
+ const { act, data } = useBackend(context);
+ const {
+ fish_info,
+ sponsored_by,
+ } = data;
+ const fish_by_name = flow([
+ sortBy(fish => fish.name),
+ ])(data.fish_info || []);
+ const [
+ currentFish,
+ setCurrentFish,
+ ] = useLocalState(context, 'currentFish', null);
+ return (
+
+
+
+
+
+ {fish_by_name.map(f => (
+
+ ))}
+
+
+
+
+ {currentFish && (
+
+
+ {currentFish.desc}
+
+
+ {currentFish.fluid}
+
+
+ {currentFish.temp_min} to {currentFish.temp_max}
+
+
+ {currentFish.feed}
+
+
+ {currentFish.source}
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+};