diff --git a/code/datums/components/seethrough.dm b/code/datums/components/seethrough.dm
index ba674f67471d7c..be52c55b7c2f39 100644
--- a/code/datums/components/seethrough.dm
+++ b/code/datums/components/seethrough.dm
@@ -15,9 +15,13 @@
var/perimeter_reset_timer
///Does this object let clicks from players its transparent to pass through it
var/clickthrough
+ // DOPPLER EDIT ADDITION START - SIGNBOARDS
+ /// Whether to always use the final turf of the parent as the "effective" parent for calculating coords.
+ var/use_parent_turf
+ // DOPPLER EDIT EDITION END
///see_through_map is a define pointing to a specific map. It's basically defining the area which is considered behind. See see_through_maps.dm for a list of maps
-/datum/component/seethrough/Initialize(see_through_map = SEE_THROUGH_MAP_DEFAULT, target_alpha = 100, animation_time = 0.5 SECONDS, perimeter_reset_timer = 2 SECONDS, clickthrough = TRUE)
+/datum/component/seethrough/Initialize(see_through_map = SEE_THROUGH_MAP_DEFAULT, target_alpha = 100, animation_time = 0.5 SECONDS, perimeter_reset_timer = 2 SECONDS, clickthrough = TRUE, use_parent_turf = FALSE, movement_source = null) // DOPPLER EDIT CHANGE - SIGNBOARDS - ORIGINAL: /datum/component/seethrough/Initialize(see_through_map = SEE_THROUGH_MAP_DEFAULT, target_alpha = 100, animation_time = 0.5 SECONDS, perimeter_reset_timer = 2 SECONDS, clickthrough = TRUE)
. = ..()
relative_turf_coords = GLOB.see_through_maps[see_through_map]
@@ -31,8 +35,9 @@
src.animation_time = animation_time
src.perimeter_reset_timer = perimeter_reset_timer
src.clickthrough = clickthrough
+ src.use_parent_turf = use_parent_turf // DOPPLER EDIT ADDITION - SIGNBOARDS
- RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(dismantle_perimeter))
+ RegisterSignal(movement_source || parent, COMSIG_MOVABLE_MOVED, PROC_REF(dismantle_perimeter)) // DOPPLER EDIT CHANGE - SIGNBOARDS - ORIGINAL: RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(dismantle_perimeter))
setup_perimeter(parent)
@@ -40,8 +45,9 @@
/datum/component/seethrough/proc/setup_perimeter(atom/parent)
watched_turfs = list()
+ var/atom/effective_parent = use_parent_turf ? get_turf(parent) : parent // DOPPLER EDIT ADDITION - SIGNBOARDS
for(var/list/coordinates as anything in relative_turf_coords)
- var/turf/target = TURF_FROM_COORDS_LIST(list(parent.x + coordinates[1], parent.y + coordinates[2], parent.z + coordinates[3]))
+ var/turf/target = TURF_FROM_COORDS_LIST(list(effective_parent.x + coordinates[1], effective_parent.y + coordinates[2], effective_parent.z + coordinates[3])) // DOPPLER EDIT CHANGE - SIGNBOARDS - ORIGINAL: var/turf/target = TURF_FROM_COORDS_LIST(list(parent.x + coordinates[1], parent.y + coordinates[2], parent.z + coordinates[3]))
if(isnull(target))
continue
diff --git a/code/modules/unit_tests/unit_test.dm b/code/modules/unit_tests/unit_test.dm
index be08e4f82ae315..8341dd492425c4 100644
--- a/code/modules/unit_tests/unit_test.dm
+++ b/code/modules/unit_tests/unit_test.dm
@@ -360,6 +360,10 @@ GLOBAL_VAR_INIT(focused_tests, focused_tests())
returnable_list += typesof(/turf/open/openspace)
returnable_list += typesof(/obj/item/robot_model) // These should never be spawned outside of a robot.
+ // DOPPLER EDIT ADDITION START - SIGNBOARDS
+ returnable_list += /obj/effect/abstract/signboard_holder // shouldn't exist outside of signboards
+ // DOPPLER EDIT ADDITION END
+
return returnable_list
/proc/RunUnitTests()
diff --git a/modular_doppler/signboards/code/_signboard.dm b/modular_doppler/signboards/code/_signboard.dm
new file mode 100644
index 00000000000000..4d5cbc4fb4a696
--- /dev/null
+++ b/modular_doppler/signboards/code/_signboard.dm
@@ -0,0 +1,233 @@
+#define INVESTIGATE_SIGNBOARD "signboard"
+#define SIGNBOARD_WIDTH (ICON_SIZE_X * 3.5)
+#define SIGNBOARD_HEIGHT (ICON_SIZE_Y * 2.5)
+
+/obj/structure/signboard
+ name = "sign"
+ desc = "A foldable sign."
+ icon = 'modular_doppler/signboards/icons/signboards.dmi'
+ icon_state = "sign"
+ base_icon_state = "sign"
+ density = TRUE
+ anchored = TRUE
+ interaction_flags_atom = INTERACT_ATOM_ATTACK_HAND | INTERACT_ATOM_REQUIRES_DEXTERITY
+ custom_materials = list(/datum/material/wood = SHEET_MATERIAL_AMOUNT * 5)
+ /// The current text written on the sign.
+ var/sign_text
+ /// The maximum length of text that can be input onto the sign.
+ var/max_length = MAX_PLAQUE_LEN
+ /// If true, the text cannot be changed by players.
+ var/locked = FALSE
+ /// If text should be shown while unanchored.
+ var/show_while_unanchored = FALSE
+ /// If TRUE, the sign can be edited without a pen.
+ var/edit_by_hand = FALSE
+ /// Holder for signboard maptext
+ var/obj/effect/abstract/signboard_holder/text_holder
+
+/obj/structure/signboard/Initialize(mapload)
+ . = ..()
+ text_holder = new(src)
+ vis_contents += text_holder
+ if(sign_text)
+ set_text(sign_text, force = TRUE)
+ investigate_log("had its text set on load to \"[sign_text]\"", INVESTIGATE_SIGNBOARD)
+ update_appearance()
+ register_context()
+
+/obj/structure/signboard/Destroy()
+ vis_contents -= text_holder
+ QDEL_NULL(text_holder)
+ return ..()
+
+/obj/structure/signboard/add_context(atom/source, list/context, obj/item/held_item, mob/user)
+ . = ..()
+ if(!is_locked(user))
+ if(held_item?.tool_behaviour == TOOL_WRENCH)
+ context[SCREENTIP_CONTEXT_LMB] = anchored ? "Unsecure" : "Secure"
+ return CONTEXTUAL_SCREENTIP_SET
+ if((edit_by_hand || IS_WRITING_UTENSIL(held_item)) && (anchored || show_while_unanchored))
+ context[SCREENTIP_CONTEXT_LMB] = "Set Displayed Text"
+ if(sign_text)
+ context[SCREENTIP_CONTEXT_ALT_RMB] = "Clear Sign"
+ return CONTEXTUAL_SCREENTIP_SET
+
+/obj/structure/signboard/examine(mob/user)
+ . = ..()
+ if(!edit_by_hand)
+ . += span_info("You need a pen to write on the sign!")
+ if(anchored)
+ . += span_info("It is secured to the floor, you could use a wrench to unsecure and move it.")
+ else
+ . += span_info("It is unsecured, you could use a wrench to secure it in place.")
+ if(sign_text)
+ . += span_boldnotice("\nIt currently displays the following:")
+ . += span_info(html_encode(sign_text))
+ else
+ . += span_info("\nIt is blank!")
+
+/obj/structure/signboard/update_icon_state()
+ . = ..()
+ icon_state = "[base_icon_state][sign_text ? "" : "_blank"]"
+
+/obj/structure/signboard/vv_edit_var(var_name, var_value)
+ if(var_name == NAMEOF(src, sign_text))
+ if(!set_text(var_value, force = TRUE))
+ return FALSE
+ datum_flags |= DF_VAR_EDITED
+ return TRUE
+ return ..()
+
+/obj/structure/signboard/item_interaction(mob/living/user, obj/item/tool, list/modifiers)
+ if(!IS_WRITING_UTENSIL(tool))
+ return NONE
+ if(try_set_text(user))
+ return ITEM_INTERACT_SUCCESS
+ else
+ return ITEM_INTERACT_BLOCKING
+
+/obj/structure/signboard/attack_hand(mob/living/user, list/modifiers)
+ . = ..()
+ if(.)
+ return
+ if(!edit_by_hand && !is_holding_pen(user))
+ balloon_alert(user, "need a pen!")
+ return TRUE
+ if(try_set_text(user))
+ return TRUE
+
+/obj/structure/signboard/proc/try_set_text(mob/living/user)
+ . = FALSE
+ if(!anchored && !show_while_unanchored)
+ return FALSE
+ if(check_locked(user))
+ return FALSE
+ var/new_text = tgui_input_text(
+ user,
+ message = "What would you like to set this sign's text to?",
+ title = full_capitalize(name),
+ default = sign_text,
+ max_length = max_length,
+ multiline = TRUE,
+ encode = FALSE
+ )
+ if(QDELETED(src) || !new_text || check_locked(user))
+ return FALSE
+ var/list/filter_result = CAN_BYPASS_FILTER(user) ? null : is_ic_filtered(new_text)
+ if(filter_result)
+ REPORT_CHAT_FILTER_TO_USER(user, filter_result)
+ return FALSE
+ var/list/soft_filter_result = CAN_BYPASS_FILTER(user) ? null : is_soft_ic_filtered(new_text)
+ if(soft_filter_result)
+ if(tgui_alert(user, "Your message contains \"[soft_filter_result[CHAT_FILTER_INDEX_WORD]]\". \"[soft_filter_result[CHAT_FILTER_INDEX_REASON]]\", Are you sure you want to say it?", "Soft Blocked Word", list("Yes", "No")) != "Yes")
+ return FALSE
+ message_admins("[ADMIN_LOOKUPFLW(user)] has passed the soft filter for \"[soft_filter_result[CHAT_FILTER_INDEX_WORD]]\" when writing to the sign at [ADMIN_VERBOSEJMP(src)], they may be using a disallowed term. Sign text: \"[html_encode(new_text)]\"")
+ log_admin_private("[key_name(user)] has passed the soft filter for \"[soft_filter_result[CHAT_FILTER_INDEX_WORD]]\" when writing to the sign at [loc_name(src)], they may be using a disallowed term. Sign text: \"[new_text]\"")
+ if(set_text(new_text))
+ balloon_alert(user, "set text")
+ investigate_log("([key_name(user)]) set text to \"[sign_text || "(none)"]\"", INVESTIGATE_SIGNBOARD)
+ return TRUE
+
+/obj/structure/signboard/click_alt_secondary(mob/user)
+ . = ..()
+ if(!sign_text || !can_interact(user) || !user.can_perform_action(src, NEED_DEXTERITY))
+ return
+ if(!edit_by_hand && !is_holding_pen(user))
+ balloon_alert(user, "need a pen!")
+ return
+ if(check_locked(user))
+ return
+ if(set_text(null))
+ balloon_alert(user, "cleared text")
+ investigate_log("([key_name(user)]) cleared the text", INVESTIGATE_SIGNBOARD)
+
+/obj/structure/signboard/wrench_act(mob/living/user, obj/item/tool)
+ . = ..()
+ if(!anchored || !check_locked(user))
+ default_unfasten_wrench(user, tool)
+ return ITEM_INTERACT_SUCCESS
+
+/obj/structure/signboard/on_changed_z_level(turf/old_turf, turf/new_turf, same_z_layer, notify_contents)
+ if(!same_z_layer)
+ SET_PLANE_EXPLICIT(text_holder, ABOVE_GAME_PLANE, src)
+ return ..()
+
+/obj/structure/signboard/set_anchored(anchorvalue)
+ . = ..()
+ update_text()
+
+/obj/structure/signboard/proc/is_locked(mob/user)
+ . = locked
+ if(isAdminGhostAI(user))
+ return FALSE
+
+/obj/structure/signboard/proc/check_locked(mob/user, silent = FALSE)
+ . = is_locked(user)
+ if(. && !silent)
+ balloon_alert(user, "locked!")
+
+/obj/structure/signboard/proc/should_display_text()
+ if(QDELETED(src) || !isturf(loc) || !sign_text)
+ return FALSE
+ if(!anchored && !show_while_unanchored)
+ return FALSE
+ return TRUE
+
+/obj/structure/signboard/proc/update_text()
+ PROTECTED_PROC(TRUE)
+ if(!should_display_text())
+ text_holder.maptext = null
+ return
+ var/bwidth = src.bound_width || ICON_SIZE_X
+ var/bheight = src.bound_height || ICON_SIZE_Y
+ var/text_html = MAPTEXT_GRAND9K("[html_encode(sign_text)]")
+ SET_PLANE_EXPLICIT(text_holder, ABOVE_GAME_PLANE, src)
+ text_holder.layer = ABOVE_ALL_MOB_LAYER
+ text_holder.alpha = 192
+ text_holder.maptext = text_html
+ text_holder.maptext_x = (SIGNBOARD_WIDTH - bwidth) * -0.5
+ text_holder.maptext_y = bheight
+ text_holder.maptext_width = SIGNBOARD_WIDTH
+ text_holder.maptext_height = SIGNBOARD_HEIGHT
+
+/obj/structure/signboard/proc/set_text(new_text, force = FALSE)
+ . = FALSE
+ if(QDELETED(src) || (locked && !force))
+ return
+ if(!istext(new_text) && !isnull(new_text))
+ CRASH("Attempted to set invalid signtext: [new_text]")
+ . = TRUE
+ sign_text = trim(new_text, max_length)
+ update_text()
+ update_appearance()
+
+/obj/structure/signboard/proc/is_holding_pen(mob/living/user)
+ for(var/obj/item/item in user.held_items)
+ if(IS_WRITING_UTENSIL(item))
+ return TRUE
+ return FALSE
+
+/obj/effect/abstract/signboard_holder
+ name = ""
+ icon = null
+ appearance_flags = APPEARANCE_UI_IGNORE_ALPHA | KEEP_APART
+ mouse_opacity = MOUSE_OPACITY_TRANSPARENT
+
+/obj/effect/abstract/signboard_holder/Initialize(mapload)
+ . = ..()
+ if(!istype(loc, /obj/structure/signboard) || QDELING(loc))
+ return INITIALIZE_HINT_QDEL
+ AddComponent(/datum/component/seethrough, SEE_THROUGH_MAP_THREE_X_TWO, 112, use_parent_turf = TRUE, movement_source = loc)
+
+/obj/effect/abstract/signboard_holder/Destroy(force)
+ if(!force && istype(loc, /obj/structure/signboard) && !QDELING(loc))
+ stack_trace("Tried to delete a signboard holder that's inside of a non-deleted signboard!")
+ return QDEL_HINT_LETMELIVE
+ return ..()
+
+/obj/effect/abstract/signboard_holder/forceMove(atom/destination, no_tp = FALSE, harderforce = FALSE)
+ if(harderforce)
+ return ..()
+
+#undef SIGNBOARD_HEIGHT
+#undef SIGNBOARD_WIDTH
diff --git a/modular_doppler/signboards/code/crafting.dm b/modular_doppler/signboards/code/crafting.dm
new file mode 100644
index 00000000000000..6d405f47a250a1
--- /dev/null
+++ b/modular_doppler/signboards/code/crafting.dm
@@ -0,0 +1,23 @@
+/datum/crafting_recipe/signboard
+ name = "Signboard"
+ desc = "A sign, you can write anything on it!"
+ tool_behaviors = list(TOOL_SCREWDRIVER)
+ result = /obj/structure/signboard
+ reqs = list(
+ /obj/item/stack/sheet/mineral/wood = 5,
+ )
+ time = 5 SECONDS
+ category = CAT_FURNITURE
+
+/datum/crafting_recipe/holosign
+ name = "Holographic Signboard"
+ desc = "A sign, you can write anything on it! Now available in many colors!"
+ tool_behaviors = list(TOOL_SCREWDRIVER, TOOL_MULTITOOL)
+ result = /obj/structure/signboard/holosign
+ reqs = list(
+ /obj/item/stack/sheet/iron = 5,
+ /obj/item/stack/cable_coil = 5,
+ /obj/item/stock_parts/micro_laser = 1,
+ )
+ time = 10 SECONDS
+ category = CAT_FURNITURE
diff --git a/modular_doppler/signboards/code/holosign.dm b/modular_doppler/signboards/code/holosign.dm
new file mode 100644
index 00000000000000..51d66abe3ad6a2
--- /dev/null
+++ b/modular_doppler/signboards/code/holosign.dm
@@ -0,0 +1,238 @@
+/obj/structure/signboard/holosign
+ name = "holographic sign"
+ desc = "A holographic signboard, projecting text above it."
+ icon_state = "holographic_sign"
+ base_icon_state = "holographic_sign"
+ layer = ABOVE_MOB_LAYER
+ density = FALSE
+ edit_by_hand = TRUE
+ show_while_unanchored = TRUE
+ light_range = MINIMUM_USEFUL_LIGHT_RANGE
+ light_power = 0.3
+ light_color = COLOR_CARP_TEAL
+ light_on = FALSE
+ custom_materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT * 5.05, /datum/material/glass = SMALL_MATERIAL_AMOUNT * 0.7)
+ /// If set, only IDs with this name can (un)lock the sign.
+ var/registered_owner
+ /// The current color of the sign.
+ /// The sign will be greyscale if this is set.
+ var/current_color
+
+/obj/structure/signboard/holosign/Initialize(mapload)
+ . = ..()
+ text_holder.appearance_flags &= ~RESET_COLOR // allow the text holoder to inherit our color
+ if(current_color)
+ set_color(current_color)
+ AddComponent(/datum/component/usb_port, list(
+ /obj/item/circuit_component/holo_signboard,
+ ))
+
+/obj/structure/signboard/holosign/add_context(atom/source, list/context, obj/item/held_item, mob/user)
+ . = ..()
+ if(istype(held_item, /obj/item/card/emag))
+ context[SCREENTIP_CONTEXT_LMB] = "Short Out Locking Mechanisms"
+ . = CONTEXTUAL_SCREENTIP_SET
+ if(is_locked(user))
+ return
+ if(istype(held_item, /obj/item/usb_cable))
+ context[SCREENTIP_CONTEXT_LMB] = "Connect USB Cable"
+ else if(istype(held_item?.GetID(), /obj/item/card/id))
+ context[SCREENTIP_CONTEXT_LMB] = registered_owner ? "Remove ID Lock" : "Lock To ID"
+ context[SCREENTIP_CONTEXT_RMB] = "Set Sign Color"
+ return CONTEXTUAL_SCREENTIP_SET
+
+/obj/structure/signboard/holosign/update_icon_state()
+ base_icon_state = current_color ? "[initial(base_icon_state)]_greyscale" : initial(base_icon_state)
+ . = ..()
+ if(obj_flags & EMAGGED)
+ icon_state += "_emag"
+
+/obj/structure/signboard/holosign/examine(mob/user)
+ . = ..()
+ if(obj_flags & EMAGGED)
+ . += span_warning("
Its locking mechanisms appear to be shorted out!")
+ else if(registered_owner)
+ . += span_info("
It is locked to the ID of [span_name(registered_owner)].")
+
+/obj/structure/signboard/holosign/update_overlays()
+ . = ..()
+ if(sign_text)
+ . += emissive_appearance(icon, "holographic_sign_e", src)
+
+/obj/structure/signboard/holosign/vv_edit_var(var_name, var_value)
+ if(var_name == NAMEOF(src, color) || var_name == NAMEOF(src, current_color))
+ set_color(var_value)
+ datum_flags |= DF_VAR_EDITED
+ return TRUE
+ return ..()
+
+/obj/structure/signboard/holosign/item_interaction(mob/living/user, obj/item/tool, list/modifiers)
+ var/obj/item/card/id/id = tool?.GetID()
+ if(!istype(id))
+ return ..()
+ var/trimmed_id_name = trimtext(id.registered_name)
+ if(!trimmed_id_name)
+ balloon_alert(user, "no name on id!")
+ return ITEM_INTERACT_BLOCKING
+ if(obj_flags & EMAGGED)
+ balloon_alert(user, "lock shorted out!")
+ return ITEM_INTERACT_BLOCKING
+ if(registered_owner)
+ if(!check_locked(user))
+ registered_owner = null
+ balloon_alert(user, "id lock removed")
+ investigate_log("([key_name(user)]) removed id lock", INVESTIGATE_SIGNBOARD)
+ else
+ registered_owner = trimmed_id_name
+ balloon_alert(user, "locked to id")
+ investigate_log("([key_name(user)]) added id lock for \"[registered_owner]\"", INVESTIGATE_SIGNBOARD)
+ update_appearance()
+ return ITEM_INTERACT_SUCCESS
+
+/obj/structure/signboard/holosign/is_locked(mob/living/user)
+ . = ..()
+ if(.)
+ return
+ if(registered_owner && isliving(user))
+ var/obj/item/card/id/id = user.get_idcard()
+ if(!istype(id) || QDELING(id))
+ return TRUE
+ return !cmptext(trimtext(id.registered_name), registered_owner)
+
+/obj/structure/signboard/holosign/set_text(new_text, force)
+ . = ..()
+ set_light(l_on = !!sign_text)
+
+/obj/structure/signboard/holosign/attack_hand_secondary(mob/user, list/modifiers)
+ . = ..()
+ if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN)
+ return
+ if(try_set_color(user))
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+
+/obj/structure/signboard/holosign/proc/try_set_color(mob/user)
+ . = TRUE
+ if(!can_interact(user) || !user.can_perform_action(src, NEED_DEXTERITY))
+ return FALSE
+ if(check_locked(user))
+ return
+ var/new_color = input(user, "Set Sign Color", full_capitalize(name), current_color) as color
+ if(new_color && is_color_dark_with_saturation(new_color, 25))
+ balloon_alert(user, "color too dark!")
+ return
+ if(check_locked(user))
+ return
+ set_color(new_color)
+ if(new_color)
+ balloon_alert(user, "set color to [new_color]")
+ investigate_log("([key_name(user)]) set the color to [new_color || "(none)"]", INVESTIGATE_SIGNBOARD)
+ else
+ balloon_alert(user, "unset color")
+ investigate_log("([key_name(user)]) cleared the color", INVESTIGATE_SIGNBOARD)
+
+/obj/structure/signboard/holosign/emag_act(mob/user, obj/item/card/emag/emag_card)
+ if(obj_flags & EMAGGED)
+ return FALSE
+ playsound(src, SFX_SPARKS, vol = 100, vary = TRUE, extrarange = SHORT_RANGE_SOUND_EXTRARANGE)
+ do_sparks(3, cardinal_only = FALSE, source = src)
+ balloon_alert(user, "lock broken")
+ investigate_log("was emagged by [key_name(user)] (previous owner: [registered_owner || "(none)"])", INVESTIGATE_SIGNBOARD)
+ registered_owner = null
+ obj_flags |= EMAGGED
+ update_appearance()
+
+/obj/structure/signboard/holosign/proc/sanitize_color(color)
+ . = sanitize_hexcolor(color)
+ if(!. || . == "#000000")
+ return null
+
+/obj/structure/signboard/holosign/proc/set_color(new_color)
+ new_color = sanitize_color(new_color)
+ if(!new_color)
+ current_color = null
+ remove_atom_colour(FIXED_COLOUR_PRIORITY)
+ else
+ current_color = new_color
+ add_atom_colour(new_color, FIXED_COLOUR_PRIORITY)
+ set_light(l_color = current_color || src::light_color)
+ update_appearance()
+
+/obj/item/circuit_component/holo_signboard
+ display_name = "Holographic Signboard"
+ desc = "Output text to a signboard, insert
in the message field to linebreak. Set the color to 0, 0, 0 to reset to default."
+ circuit_flags = CIRCUIT_FLAG_INPUT_SIGNAL|CIRCUIT_FLAG_OUTPUT_SIGNAL
+
+ var/datum/port/input/message
+ var/datum/port/input/clear
+
+ var/datum/port/output/fail_reason
+ var/datum/port/output/on_fail
+
+ var/datum/port/input/red
+ var/datum/port/input/green
+ var/datum/port/input/blue
+ var/datum/port/input/set_color
+
+ var/obj/structure/signboard/holosign/connected_display
+
+/obj/item/circuit_component/holo_signboard/populate_ports()
+ message = add_input_port("Message", PORT_TYPE_STRING)
+ clear = add_input_port("Clear", PORT_TYPE_SIGNAL, trigger = PROC_REF(clear_received))
+
+ fail_reason = add_output_port("Fail Reason", PORT_TYPE_STRING)
+ on_fail = add_output_port("Failed", PORT_TYPE_SIGNAL)
+
+
+ red = add_input_port("Red", PORT_TYPE_NUMBER)
+ green = add_input_port("Green", PORT_TYPE_NUMBER)
+ blue = add_input_port("Blue", PORT_TYPE_NUMBER)
+ set_color = add_input_port("Set Color", PORT_TYPE_SIGNAL, trigger = PROC_REF(color_received))
+
+/obj/item/circuit_component/holo_signboard/register_usb_parent(atom/movable/shell)
+ . = ..()
+ if(istype(shell, /obj/structure/signboard/holosign))
+ connected_display = shell
+
+/obj/item/circuit_component/holo_signboard/input_received(datum/port/input/port)
+ if(!connected_display)
+ return
+ if(length(message.value) > connected_display.max_length) //5000 is a hell of a lot longer than 144.
+ fail_reason.set_output("Too long ([length(message.value)]/[connected_display.max_length]).")
+ on_fail.set_output(COMPONENT_SIGNAL)
+ return
+ if(is_ic_filtered(message.value))
+ fail_reason.set_output("Prohibited content.")
+ on_fail.set_output(COMPONENT_SIGNAL)
+ return
+
+ var/edited_message = replacetextEx_char(message.value, "
", "\n")
+ if(connected_display.set_text(edited_message))
+ investigate_log("Circuit USB ([parent.get_creator()]) set text to \"[connected_display.sign_text || "(none)"]\"", INVESTIGATE_SIGNBOARD)
+ if(is_soft_ic_filtered(message.value))
+ message_admins("A circuit component (by [parent.get_creator_admin()]) added a soft filtered message to a signboard. [ADMIN_COORDJMP(src)]")
+ else
+ fail_reason.set_output("Connection refused by external endpoint.")
+ on_fail.set_output(COMPONENT_SIGNAL)
+
+/obj/item/circuit_component/holo_signboard/proc/clear_received(datum/port/input/port)
+ if(!connected_display.set_text(null))
+ fail_reason.set_output("Connection refused by external endpoint.")
+ on_fail.set_output(COMPONENT_SIGNAL)
+
+/obj/item/circuit_component/holo_signboard/proc/color_received(datum/port/input/port)
+ red.set_value(clamp(red.value, 0, 255))
+ blue.set_value(clamp(blue.value, 0, 255))
+ green.set_value(clamp(green.value, 0, 255))
+ var/signboard_color = rgb(red.value, green.value, blue.value)
+ if(signboard_color && signboard_color != rgb(0, 0, 0) && is_color_dark_with_saturation(signboard_color, 25))
+ fail_reason.set_output("Color too dark to display.")
+ on_fail.set_output(COMPONENT_SIGNAL)
+ return
+ connected_display.set_color(signboard_color) //doesnt have a return so no need to check and error
+ investigate_log("Circuit USB ([parent.get_creator()]) set the color to [signboard_color || "(none)"]", INVESTIGATE_SIGNBOARD)
+
+/// Given a color in the format of "#RRGGBB", will return if the color
+/// is dark. Value is mixed with Saturation and Brightness from HSV.
+/proc/is_color_dark_with_saturation(color, threshold = 25)
+ var/hsl = rgb2num(color, COLORSPACE_HSL)
+ return hsl[3] < threshold
diff --git a/modular_doppler/signboards/icons/signboards.dmi b/modular_doppler/signboards/icons/signboards.dmi
new file mode 100644
index 00000000000000..c1db06ee9b7092
Binary files /dev/null and b/modular_doppler/signboards/icons/signboards.dmi differ
diff --git a/modular_doppler/signboards/readme.md b/modular_doppler/signboards/readme.md
new file mode 100644
index 00000000000000..54d6470b12e345
--- /dev/null
+++ b/modular_doppler/signboards/readme.md
@@ -0,0 +1,33 @@
+## Signboards
+
+Module ID: SIGNBOARDS
+
+### Description:
+
+Adds signboards!
+
+### TG Proc/File Changes:
+
+- `code/modules/unit_tests/unit_test.dm`
+ - `/datum/unit_test/proc/build_list_of_uncreatables`
+- `code/datums/components/seethrough.dm`
+ - new var: `var/use_parent_turf`
+ - `/datum/component/seethrough/Initialize` (new args: `use_parent_turf`, `movement_source`)
+ - `/datum/component/seethrough/proc/setup_perimeter`
+
+### Modular Overrides:
+
+- N/A
+
+### Defines:
+
+- N/A
+
+### Included files that are not contained in this module:
+
+- N/A
+
+### Credits:
+
+Absolucy
+ancient-engineer (sprites)
diff --git a/tgstation.dme b/tgstation.dme
index c79ace1716333c..84e70ae0d1a892 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -7743,6 +7743,9 @@
#include "modular_doppler\ships_r_us\code\shuttle_templates\incomplete.dm"
#include "modular_doppler\ships_r_us\code\shuttle_templates\mining.dm"
#include "modular_doppler\ships_r_us\code\shuttle_templates\pods.dm"
+#include "modular_doppler\signboards\code\_signboard.dm"
+#include "modular_doppler\signboards\code\crafting.dm"
+#include "modular_doppler\signboards\code\holosign.dm"
#include "modular_doppler\skillchips\generic_skillchips\misc.dm"
#include "modular_doppler\soulcatcher\code\attachable_soulcatcher.dm"
#include "modular_doppler\soulcatcher\code\handheld_soulcatcher.dm"