[MIRROR] Replace the alt click menu with the RPG Lootpanel (#11170)

Co-authored-by: ShadowLarkens <shadowlarkens@gmail.com>
This commit is contained in:
CHOMPStation2StaffMirrorBot
2025-07-10 23:27:55 -07:00
committed by GitHub
parent 5a7afc12df
commit 23fee17c6d
31 changed files with 1269 additions and 490 deletions

View File

@@ -164,6 +164,7 @@ var/global/list/runlevel_flags = list(RUNLEVEL_LOBBY, RUNLEVEL_SETUP, RUNLEVEL_G
#define INIT_ORDER_MAPRENAME -60 //Initiating after Ticker to ensure everything is loaded and everything we rely on us working
#define INIT_ORDER_WIKI -61
#define INIT_ORDER_ATC -70
#define INIT_ORDER_LOOT -80
#define INIT_ORDER_STATPANELS -98
#define INIT_ORDER_CHAT -100 //Should be last to ensure chat remains smooth during init.

View File

@@ -12,3 +12,6 @@ Remember to update _globalvars/traits.dm if you're adding/removing/renaming trai
/// Climbable trait, given and taken by the climbable element when added or removed. Exists to be easily checked via HAS_TRAIT().
#define TRAIT_CLIMBABLE "trait_climbable"
/// Prevents the affected object from opening a loot window via alt click. See atom/AltClick()
#define TRAIT_ALT_CLICK_BLOCKER "no_alt_click"

View File

@@ -17,6 +17,9 @@ GLOBAL_LIST_INIT(radiation_levels, list(
/* CHOMPRemove, see traits.dm
GLOBAL_LIST_INIT(traits_by_type, list(
/atom = list(
"TRAIT_ALT_CLICK_BLOCKER" = TRAIT_ALT_CLICK_BLOCKER,
),
/mob = list(
"TRAIT_THINKING_IN_CHARACTER" = TRAIT_THINKING_IN_CHARACTER,
),

View File

@@ -293,11 +293,37 @@
A.AltClick(src)
return
/**
* Alt click on an atom.
* Performs alt-click actions before attempting to open a loot window.
* Returns TRUE if successful, FALSE if not.
*/
/atom/proc/AltClick(var/mob/user)
var/turf/T = get_turf(src)
if(T && user.TurfAdjacent(T))
user.set_listed_turf(T)
return 1
// if(!user.can_interact_with(src))
// return FALSE
// if(SEND_SIGNAL(src, COMSIG_CLICK_ALT, user) & COMPONENT_CANCEL_CLICK_ALT)
// return TRUE
if(HAS_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER) && !isobserver(user))
return TRUE
var/turf/tile = get_turf(src)
if(isnull(tile))
return FALSE
if(!isturf(loc) && !isturf(src))
return FALSE
if(!user.TurfAdjacent(tile))
return FALSE
var/datum/lootpanel/panel = user.client?.loot_panel
if(isnull(panel))
return FALSE
panel.open(tile)
return TRUE
/mob/proc/TurfAdjacent(var/turf/T)
return T.AdjacentQuick(src)

View File

@@ -41,7 +41,7 @@
if(modifiers["alt"]) // alt and alt-gr (rightalt)
var/turf/T = get_turf(A)
if(T && TurfAdjacent(T))
set_listed_turf(T)
T.AltClick(src)
return
// You are responsible for checking config.ghost_interaction when you override this function
// Not all of them require checking, see below

View File

@@ -9,7 +9,6 @@ SUBSYSTEM_DEF(statpanels)
var/list/currentrun = list()
var/list/global_data
var/list/mc_data
var/list/cached_images = list()
///how many subsystem fires between most tab updates
var/default_wait = 10
@@ -102,12 +101,6 @@ SUBSYSTEM_DEF(statpanels)
if((num_fires % misc_wait == 0))
update_misc_tabs(target,target_mob)
var/datum/object_window_info/obj_window = target.obj_window
if(obj_window)
if(obj_window.flags & TURFLIST_UPDATE_QUEUED)
immediate_send_stat_data(target)
obj_window.flags = 0
if(MC_TICK_CHECK)
return
@@ -153,14 +146,17 @@ SUBSYSTEM_DEF(statpanels)
var/description_holders = target.description_holders
var/list/examine_update = list()
if(!target.obj_window)
target.obj_window = new(target)
if(!target.examine_icon && !target.obj_window.examine_target && target.stat_tab == "Examine")
target.obj_window.examine_target = description_holders["icon"]
target.obj_window.atoms_to_show += target.obj_window.examine_target
START_PROCESSING(SSobj_tab_items, target.obj_window)
refresh_client_obj_view(target)
examine_update += "[target.examine_icon]&emsp;" + span_giant("[description_holders["name"]]") //The name, written in big letters.
var/atom/atom_icon = description_holders["icon"]
var/shown_icon = target.examine_icon
if(!shown_icon)
if(ismob(atom_icon) || length(atom_icon.overlays) > 0)
var/force_south = FALSE
if(isliving(atom_icon))
force_south = TRUE
shown_icon = costly_icon2html(atom_icon, target, sourceonly=TRUE, force_south = force_south)
else
shown_icon = icon2html(atom_icon, target, sourceonly=TRUE)
examine_update += "<img src=\"[shown_icon]\" />&emsp;" + span_giant("[description_holders["name"]]") //The name, written in big letters.
examine_update += "[description_holders["desc"]]" //the default examine text.
if(description_holders["info"])
examine_update += span_blue(span_bold("[replacetext(description_holders["info"], "\n", "<BR>")]")) + "<br />" //Blue, informative text.
@@ -205,81 +201,6 @@ SUBSYSTEM_DEF(statpanels)
//target.stat_panel.send_message("update_spells", list(spell_tabs = target.spell_tabs, actions = actions))
/datum/controller/subsystem/statpanels/proc/set_turf_examine_tab(client/target, mob/target_mob)
if(!target)//statbrowser hasnt fired yet and we were called from immediate_send_stat_data()
return
var/list/overrides = list()
for(var/image/target_image as anything in target.images)
if(!target_image.loc || target_image.loc.loc != target.tracked_turf || !target_image.override)
continue
overrides += target_image.loc
var/list/atoms_to_display = list(target.tracked_turf)
for(var/atom/movable/turf_content as anything in target.tracked_turf)
if(turf_content.mouse_opacity == MOUSE_OPACITY_TRANSPARENT)
continue
if(turf_content.invisibility > target_mob.see_invisible)
continue
if(turf_content in overrides)
continue
//if(turf_content.IsObscured())
//continue
atoms_to_display += turf_content
/// Set the atoms we're meant to display
var/datum/object_window_info/obj_window = target.obj_window
if(!obj_window)
return // previous one no longer exists
obj_window.atoms_to_show = atoms_to_display
START_PROCESSING(SSobj_tab_items, obj_window)
refresh_client_obj_view(target)
/datum/controller/subsystem/statpanels/proc/refresh_client_obj_view(client/refresh)
var/list/turf_items = return_object_images(refresh)
if(!length(turf_items)/* || !refresh.mob?.listed_turf*/)
return
refresh.stat_panel.send_message("update_listedturf", turf_items)
#define OBJ_IMAGE_LOADING "statpanels obj loading temporary"
/// Returns all our ready object tab images
/// Returns a list in the form list(list(object_name, object_ref, loaded_image), ...)
/datum/controller/subsystem/statpanels/proc/return_object_images(client/load_from)
// You might be inclined to think that this is a waste of cpu time, since we
// A: Double iterate over atoms in the build case, or
// B: Generate these lists over and over in the refresh case
// It's really not very hot. The hot portion of this code is genuinely mostly in the image generation
// So it's ok to pay a performance cost for cleanliness here
// No turf? go away
/*if(!load_from.mob?.listed_turf)
return list()*/
var/datum/object_window_info/obj_window = load_from.obj_window
var/list/already_seen = obj_window.atoms_to_images
var/list/to_make = obj_window.atoms_to_imagify
var/list/turf_items = list()
for(var/atom/turf_item as anything in obj_window.atoms_to_show)
// First, we fill up the list of refs to display
// If we already have one, just use that
var/existing_image = already_seen[turf_item]
if(existing_image == OBJ_IMAGE_LOADING)
continue
// We already have it. Success!
if(existing_image)
if(turf_item == obj_window.examine_target) //not actually a turf item get trolled
load_from.examine_icon = "<img src=\"[existing_image]\" />"
obj_window.examine_target = null
set_examine_tab(load_from)
continue
turf_items[++turf_items.len] = list("[turf_item.name]", REF(turf_item), existing_image)
continue
// Now, we're gonna queue image generation out of those refs
to_make += turf_item
already_seen[turf_item] = OBJ_IMAGE_LOADING
obj_window.RegisterSignal(turf_item, COMSIG_PARENT_QDELETING, TYPE_PROC_REF(/datum/object_window_info,viewing_atom_deleted)) // we reset cache if anything in it gets deleted
return turf_items
#undef OBJ_IMAGE_LOADING
/datum/controller/subsystem/statpanels/proc/generate_mc_data()
mc_data = list(
list("CPU:", world.cpu),
@@ -324,16 +245,6 @@ SUBSYSTEM_DEF(statpanels)
set_action_tabs(target, target_mob)
return TRUE
// Handle turfs
if(target.tracked_turf)
if(!target_mob.TurfAdjacent(target.tracked_turf))
target_mob.set_listed_turf(null)
else if(target.stat_tab == target.tracked_turf.name || !(target.tracked_turf.name in target.panel_tabs))
set_turf_examine_tab(target, target_mob)
return TRUE
if(!target.holder)
return FALSE
@@ -351,136 +262,5 @@ SUBSYSTEM_DEF(statpanels)
else if(length(GLOB.sdql2_queries) && target.stat_tab == "SDQL2")
set_SDQL2_tab(target)
/atom/proc/remove_from_cache()
SIGNAL_HANDLER
SSstatpanels.cached_images -= REF(src)
/// Stat panel window declaration
/client/var/datum/tgui_window/stat_panel
/// Turf examine turf
/client/var/turf/tracked_turf
/// Datum that holds and tracks info about a client's object window
/// Really only exists because I want to be able to do logic with signals
/// And need a safe place to do the registration
/datum/object_window_info
/// list of atoms to show to our client via the object tab, at least currently
var/list/atoms_to_show = list()
/// list of atom -> image string for objects we have had in the right click tab
/// this is our caching
var/list/atoms_to_images = list()
/// list of atoms to turn into images for the object tab
var/list/atoms_to_imagify = list()
/// Our owner client
var/client/parent
///For reusing this logic for examines
var/atom/examine_target
var/flags = 0
/datum/object_window_info/New(client/parent)
. = ..()
src.parent = parent
/datum/object_window_info/Destroy(force, ...)
atoms_to_show = null
atoms_to_images = null
atoms_to_imagify = null
parent.obj_window = null
parent = null
STOP_PROCESSING(SSobj_tab_items, src)
return ..()
/// Takes a client, attempts to generate object images for it
/// We will update the client with any improvements we make when we're done
/datum/object_window_info/process(seconds_per_tick)
// Cache the datum access for sonic speed
var/list/to_make = atoms_to_imagify
var/list/newly_seen = atoms_to_images
var/index = 0
for(index in 1 to length(to_make))
var/atom/thing = to_make[index]
if(!thing) // A null thing snuck in somehow
continue
var/generated_string
if(ismob(thing) || length(thing.overlays) > 0)
var/force_south = FALSE
if(isliving(thing))
force_south = TRUE
generated_string = costly_icon2html(thing, parent, sourceonly=TRUE, force_south = force_south)
else
generated_string = icon2html(thing, parent, sourceonly=TRUE)
newly_seen[thing] = generated_string
if(TICK_CHECK)
to_make.Cut(1, index + 1)
index = 0
break
// If we've not cut yet, do it now
if(index)
to_make.Cut(1, index + 1)
SSstatpanels.refresh_client_obj_view(parent)
if(!length(to_make))
return PROCESS_KILL
/datum/object_window_info/proc/start_turf_tracking(turf/new_turf)
if(parent.tracked_turf)
stop_turf_tracking()
var/static/list/connections = list(
COMSIG_MOVABLE_MOVED = PROC_REF(on_mob_move),
COMSIG_MOB_LOGOUT = PROC_REF(on_mob_logout),
)
AddComponent(/datum/component/connect_mob_behalf, parent, connections)
RegisterSignal(new_turf, COMSIG_ATOM_ENTERED, PROC_REF(turflist_changed))
RegisterSignal(new_turf, COMSIG_ATOM_EXITED, PROC_REF(turflist_changed))
parent.stat_panel.send_message("create_listedturf", new_turf)
parent.tracked_turf = new_turf
/datum/object_window_info/proc/stop_turf_tracking()
if(GetComponent(/datum/component/connect_mob_behalf))
qdel(GetComponent(/datum/component/connect_mob_behalf))
if(parent.tracked_turf)
UnregisterSignal(parent.tracked_turf, COMSIG_ATOM_ENTERED)
UnregisterSignal(parent.tracked_turf, COMSIG_ATOM_EXITED)
parent.stat_panel.send_message("remove_listedturf")
parent.tracked_turf = null
/datum/object_window_info/proc/on_mob_move(mob/source)
SIGNAL_HANDLER
if(!parent.tracked_turf || !source.TurfAdjacent(parent.tracked_turf))
source.set_listed_turf(null)
/datum/object_window_info/proc/on_mob_logout(mob/source)
SIGNAL_HANDLER
on_mob_move(parent.mob)
/datum/object_window_info/proc/turflist_changed(mob/source)
SIGNAL_HANDLER
if(!parent)//statbrowser hasnt fired yet and we still have a pending action
return
if(!(flags & TURFLIST_UPDATED)) //Limit updates to 1 per tick
SSstatpanels.immediate_send_stat_data(parent)
flags |= TURFLIST_UPDATED
else if(!(flags & TURFLIST_UPDATE_QUEUED))
flags |= TURFLIST_UPDATE_QUEUED
/// Clears any cached object window stuff
/// We use hard refs cause we'd need a signal for this anyway. Cleaner this way
/datum/object_window_info/proc/viewing_atom_deleted(atom/deleted)
SIGNAL_HANDLER
atoms_to_show -= deleted
atoms_to_imagify -= deleted
atoms_to_images -= deleted
/mob/proc/set_listed_turf(turf/new_turf)
if(!client)
return
if(!client.obj_window)
client.obj_window = new(client)
if(client.tracked_turf == new_turf)
return
if(!new_turf)
client.obj_window.stop_turf_tracking() //Needs to go before listed_turf is set to null so signals can be removed
return
client.obj_window.start_turf_tracking(new_turf)

View File

@@ -99,7 +99,6 @@
if(new_character.client)
new_character.client.init_verbs() // re-initialize character specific verbs
new_character.set_listed_turf(null)
/datum/mind/proc/store_memory(new_text)
memory += "[new_text]<BR>"

View File

@@ -398,6 +398,7 @@
// called just as an item is picked up (loc is not yet changed)
/obj/item/proc/pickup(mob/user)
SEND_SIGNAL(src, COMSIG_ITEM_PICKUP, user)
pixel_x = 0
pixel_y = 0
return

View File

@@ -29,6 +29,7 @@
var/pass_color = FALSE // Will the item pass its own color var to the created item? Dyed cloth, wood, etc.
var/strict_color_stacking = FALSE // Will the stack merge with other stacks that are different colors? (Dyed cloth, wood, etc)
var/is_building = FALSE
/obj/item/stack/Initialize(mapload, var/starting_amount)
. = ..()
@@ -158,6 +159,9 @@
var/required = quantity*recipe.req_amount
var/produced = min(quantity*recipe.res_amount, recipe.max_res_amount)
if(is_building)
return
if (!can_use(required))
if (produced>1)
to_chat(user, span_warning("You haven't got enough [src] to build \the [produced] [recipe.title]\s!"))
@@ -175,9 +179,12 @@
if (recipe.time)
to_chat(user, span_notice("Building [recipe.title] ..."))
is_building = TRUE
if (!do_after(user, recipe.time))
is_building = FALSE
return
is_building = FALSE
if (use(required))
var/atom/O
if(recipe.use_material)

View File

@@ -181,7 +181,7 @@
to_chat(user, span_notice("You can taste the sweet flavor of digital friendship. Or maybe it is something else."))
/obj/item/reagent_containers/food/after_trash_eaten(var/mob/living/user)
if(!reagents.total_volume)
if(!reagents?.total_volume)
to_chat(user, span_notice("You can taste the flavor of garbage and leftovers. Delicious?"))
else
to_chat(user, span_notice("You can taste the flavor of gluttonous waste of food."))

View File

@@ -184,3 +184,6 @@
/// The DPI scale of the client. 1 is equivalent to 100% window scaling, 2 will be 200% window scaling
var/window_scaling
/// Loot panel for the client
var/datum/lootpanel/loot_panel

View File

@@ -305,6 +305,8 @@
tgui_say.initialize()
tgui_shocker.initialize()
loot_panel = new(src)
connection_time = world.time
connection_realtime = world.realtime
connection_timeofday = world.timeofday
@@ -392,6 +394,7 @@
GLOB.directory -= ckey
GLOB.clients -= src
QDEL_NULL(loot_panel)
..()
return QDEL_HINT_HARDDEL_NOW

View File

@@ -53,7 +53,7 @@
/datum/category_item/player_setup_item/general/nif/copy_to_mob(var/mob/living/carbon/human/character)
//If you had a NIF...
if(istype(character) && ispath(pref.nif_path) && pref.nif_durability)
if(istype(character) && ispath(pref.nif_path) && pref.nif_durability && !ismannequin(character))
new pref.nif_path(character, pref.nif_durability, pref.nif_savedata)
/datum/category_item/player_setup_item/general/nif/tgui_data(mob/user, datum/tgui/ui, datum/tgui_state/state)

View File

@@ -0,0 +1,80 @@
/**
* ## Loot panel
* A datum that stores info containing the contents of a turf.
* Handles opening the lootpanel UI and searching the turf for items.
*/
/datum/lootpanel
/// The owner of the panel
var/client/owner
/// The list of all search objects indexed.
var/list/datum/search_object/contents = list()
/// The list of search_objects needing processed
var/list/datum/search_object/to_image = list()
/// We've been notified about client version
var/notified = FALSE
/// The turf being searched
var/turf/source_turf
/datum/lootpanel/New(client/owner)
. = ..()
src.owner = owner
/datum/lootpanel/Destroy(force)
reset_contents()
owner = null
source_turf = null
return ..()
/datum/lootpanel/tgui_interact(mob/user, datum/tgui/ui)
ui = SStgui.try_update_ui(user, src, ui)
if(!ui)
ui = new(user, src, "LootPanel")
ui.set_autoupdate(FALSE)
ui.open()
/datum/lootpanel/tgui_close(mob/user)
. = ..()
source_turf = null
reset_contents()
/datum/lootpanel/tgui_data(mob/user)
var/list/data = list()
data["contents"] = get_contents()
data["is_blind"] = !!user.is_blind()
data["searching"] = length(to_image)
return data
/datum/lootpanel/tgui_status(mob/user, datum/tgui_state/state)
// note: different from /tg/, we prohibit non-viewers from trying to update the window and close it automatically for them
if(!(user in viewers(source_turf)))
return STATUS_CLOSE
if(user.incapacitated())
return STATUS_DISABLED
return STATUS_INTERACTIVE
/datum/lootpanel/tgui_act(action, list/params, datum/tgui/ui, datum/tgui_state/state)
. = ..()
if(.)
return
switch(action)
if("grab")
return grab(usr, params)
if("refresh")
return populate_contents()
return FALSE

View File

@@ -0,0 +1,54 @@
/// Adds the item to contents and to_image (if needed)
/datum/lootpanel/proc/add_to_index(datum/search_object/index)
RegisterSignal(index, COMSIG_PARENT_QDELETING, PROC_REF(on_searchable_deleted))
if(isnull(index.icon))
to_image += index
contents += index
/// Used to populate contents and start generating if needed
/datum/lootpanel/proc/populate_contents()
if(length(contents))
reset_contents()
// Add source turf first
var/datum/search_object/source = new(owner, source_turf)
add_to_index(source)
for(var/atom/thing as anything in source_turf.contents)
// validate
if(!istype(thing))
stack_trace("Non-atom in the contents of [source_turf]!")
continue
if(QDELETED(thing))
continue
if(thing.mouse_opacity == MOUSE_OPACITY_TRANSPARENT)
continue
// if(thing.IsObscured())
// continue
if(thing.invisibility > owner.mob.see_invisible)
continue
// convert
var/datum/search_object/index = new(owner, thing)
add_to_index(index)
var/datum/tgui/window = SStgui.get_open_ui(owner.mob, src)
window?.send_update()
if(length(to_image))
SSlooting.backlog += src
/// For: Resetting to empty. Ignores the searchable qdel event
/datum/lootpanel/proc/reset_contents()
for(var/datum/search_object/index as anything in contents)
contents -= index
to_image -= index
if(QDELETED(index))
continue
UnregisterSignal(index, COMSIG_PARENT_QDELETING)
qdel(index)

View File

@@ -0,0 +1,19 @@
/// On contents change, either reset or update
/datum/lootpanel/proc/on_searchable_deleted(datum/search_object/source)
SIGNAL_HANDLER
contents -= source
to_image -= source
var/datum/tgui/window = SStgui.get_open_ui(owner.mob, src)
#if !defined(UNIT_TESTS) // we dont want to delete contents if we're testing
if(isnull(window))
reset_contents()
return
#endif
if(isturf(source.item))
populate_contents()
return
window?.send_update()

View File

@@ -0,0 +1,48 @@
/// Helper to open the panel
/datum/lootpanel/proc/open(turf/tile)
source_turf = tile
#if !defined(OPENDREAM) && !defined(UNIT_TESTS)
if(!notified)
var/build = owner.byond_build
var/version = owner.byond_version
if(build < 515 || (build == 515 && version < 1635))
to_chat(owner.mob, span_info("\
<span class='bolddanger'>Your version of Byond doesn't support fast image loading.</span>\n\
Detected: [version].[build]\n\
Required version for this feature: <b>515.1635</b> or later.\n\
Visit <a href=\"https://secure.byond.com/download\">BYOND's website</a> to get the latest version of BYOND.\n\
"))
notified = TRUE
#endif
populate_contents()
tgui_interact(owner.mob)
/**
* Called by SSlooting whenever this datum is added to its backlog.
* Iterates over to_image list to create icons, then removes them.
* Returns boolean - whether this proc has finished the queue or not.
*/
/datum/lootpanel/proc/process_images()
for(var/datum/search_object/index as anything in to_image)
to_image -= index
if(QDELETED(index) || index.icon)
continue
index.generate_icon(owner)
if(TICK_CHECK)
break
var/datum/tgui/window = SStgui.get_open_ui(owner.mob, src)
if(isnull(window))
reset_contents()
return TRUE
window.send_update()
return !length(to_image)

View File

@@ -0,0 +1,89 @@
/**
* ## Search Object
* An object for content lists. Compacted item data.
*/
/datum/search_object
/// Item we're indexing
var/atom/item
/// Url to the image of the object
var/icon
/// Icon state, for inexpensive icons
var/icon_state
/// Name of the original object
var/name
/// Typepath of the original object for ui grouping
var/path
/datum/search_object/New(client/owner, atom/item)
. = ..()
src.item = item
name = item.name
if(isobj(item))
path = item.type
if(isturf(item))
RegisterSignal(item, COMSIG_TURF_CHANGE, PROC_REF(on_turf_change))
else
// Lest we find ourselves here again, this is intentionally stupid.
// It tracks items going out and user actions, otherwise they can refresh the lootpanel.
// If this is to be made to track everything, we'll need to make a new signal to specifically create/delete a search object
RegisterSignals(item, list(
COMSIG_ITEM_PICKUP,
COMSIG_MOVABLE_MOVED,
COMSIG_PARENT_QDELETING,
), PROC_REF(on_item_moved))
// Icon generation conditions //////////////
// Condition 1: Icon is complex
if(ismob(item) || length(item.overlays) > 0)
return
// Condition 2: Can't get icon path
if(!isfile(item.icon) || !length("[item.icon]"))
return
// Condition 3: Using opendream
#if defined(OPENDREAM) || defined(UNIT_TESTS)
return
#endif
// Condition 4: Using older byond version
var/build = owner.byond_build
var/version = owner.byond_version
if(build < 515 || (build == 515 && version < 1635))
icon = "n/a"
return
icon = "[item.icon]"
icon_state = item.icon_state
/datum/search_object/Destroy(force)
item = null
icon = null
return ..()
/// Generates the icon for the search object. This is the expensive part.
/datum/search_object/proc/generate_icon(client/owner)
icon = costly_icon2html(item, owner, sourceonly = TRUE)
/// Parent item has been altered, search object no longer valid
/datum/search_object/proc/on_item_moved(atom/source)
SIGNAL_HANDLER
if(QDELETED(src))
return
qdel(src)
/// Parent tile has been altered, entire search needs reset
/datum/search_object/proc/on_turf_change(turf/source, path, list/new_baseturfs, flags, list/post_change_callbacks)
SIGNAL_HANDLER
post_change_callbacks += CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(qdel), src)

View File

@@ -0,0 +1,40 @@
/// Queues image generation for search objects without icons
SUBSYSTEM_DEF(looting)
name = "Loot Icon Generation"
flags = SS_NO_INIT
priority = FIRE_PRIORITY_PROCESS
runlevels = RUNLEVEL_LOBBY|RUNLEVELS_DEFAULT
wait = 0.5 SECONDS
/// Backlog of items. Gets put into processing
var/list/datum/lootpanel/backlog = list()
/// Actively processing items
var/list/datum/lootpanel/processing = list()
/datum/controller/subsystem/looting/stat_entry(msg)
msg = "P:[length(backlog)]"
return ..()
/datum/controller/subsystem/looting/fire(resumed)
if(!length(backlog))
return
if(!resumed)
processing = backlog
backlog = list()
while(length(processing))
var/datum/lootpanel/panel = processing[length(processing)]
if(QDELETED(panel) || !length(panel.to_image))
processing.len--
continue
if(!panel.process_images())
backlog += panel
if(MC_TICK_CHECK)
return
processing.len--

View File

@@ -0,0 +1,47 @@
/// UI helper for converting the associative list to a list of lists
/datum/lootpanel/proc/get_contents()
var/list/items = list()
for(var/datum/search_object/index as anything in contents)
UNTYPED_LIST_ADD(items, list(
"icon_state" = index.icon_state,
"icon" = index.icon,
"name" = index.name,
"path" = index.path,
"ref" = REF(index),
))
return items
/// Clicks an object from the contents. Validates the object and the user
/datum/lootpanel/proc/grab(mob/user, list/params)
var/ref = params["ref"]
if(isnull(ref))
return FALSE
var/datum/search_object/index = locate(ref) in contents
var/atom/thing = index?.item
if(QDELETED(index) || QDELETED(thing)) // Obj is gone
return FALSE
if(thing != source_turf && !(locate(thing) in source_turf.contents))
qdel(index) // Item has moved
return TRUE
var/modifiers = ""
if(params["ctrl"])
modifiers += "ctrl=1;"
if(params["middle"])
modifiers += "middle=1;"
if(params["shift"])
modifiers += "shift=1;"
if(params["alt"])
modifiers += "alt=1;"
if(params["right"])
modifiers += "right=1;"
user.ClickOn(thing, modifiers)
return TRUE

View File

@@ -744,11 +744,6 @@
var/mob/M = locate(href_list["lookmob"])
src.examinate(M)
if (href_list["clickitem"])
var/obj/item/I = locate(href_list["clickitem"])
if(src.client)
src.ClickOn(I)
if (href_list["flavor_change"])
switch(href_list["flavor_change"])
if("done")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
import { globalEvents, type KeyEvent } from 'tgui-core/events';
import * as keycodes from 'tgui-core/keycodes';
import { logger } from './logging';
// BYOND macros, in `key: command` format.
const byondMacros: Record<string, string> = {};
// Default set of acquired keys, which will not be sent to BYOND.
const hotKeysAcquired = [
keycodes.KEY_ESCAPE,
keycodes.KEY_ENTER,
keycodes.KEY_SPACE,
keycodes.KEY_TAB,
keycodes.KEY_CTRL,
keycodes.KEY_SHIFT,
keycodes.KEY_UP,
keycodes.KEY_DOWN,
keycodes.KEY_LEFT,
keycodes.KEY_RIGHT,
keycodes.KEY_F5,
];
// State of passed-through keys.
const keyState: Record<string, boolean> = {};
// Custom listeners for key events
const keyListeners: ((key: KeyEvent) => void)[] = [];
/**
* Converts a browser keycode to BYOND keycode.
*/
function keyCodeToByond(keyCode: number) {
if (keyCode === 16) return 'Shift';
if (keyCode === 17) return 'Ctrl';
if (keyCode === 18) return 'Alt';
if (keyCode === 33) return 'Northeast';
if (keyCode === 34) return 'Southeast';
if (keyCode === 35) return 'Southwest';
if (keyCode === 36) return 'Northwest';
if (keyCode === 37) return 'West';
if (keyCode === 38) return 'North';
if (keyCode === 39) return 'East';
if (keyCode === 40) return 'South';
if (keyCode === 45) return 'Insert';
if (keyCode === 46) return 'Delete';
if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) {
return String.fromCharCode(keyCode);
}
if (keyCode >= 96 && keyCode <= 105) {
return `Numpad${keyCode - 96}`;
}
if (keyCode >= 112 && keyCode <= 123) {
return `F${keyCode - 111}`;
}
if (keyCode === 188) return ',';
if (keyCode === 189) return '-';
if (keyCode === 190) return '.';
}
/**
* Keyboard passthrough logic. This allows you to keep doing things
* in game while the browser window is focused.
*/
function handlePassthrough(key: KeyEvent) {
const keyString = String(key);
// In addition to F5, support reloading with Ctrl+R and Ctrl+F5
if (keyString === 'Ctrl+F5' || keyString === 'Ctrl+R') {
location.reload();
return;
}
// Prevent passthrough on Ctrl+F
if (keyString === 'Ctrl+F') {
return;
}
// NOTE: Alt modifier is pretty bad and sticky in IE11.
if (
key.event.defaultPrevented ||
key.isModifierKey() ||
hotKeysAcquired.includes(key.code) ||
key.repeat // no repeating
) {
return;
}
const byondKeyCode = keyCodeToByond(key.code);
if (!byondKeyCode) {
return;
}
let byondKeyCodeIdent = byondKeyCode;
if (key.isUp()) {
byondKeyCodeIdent += '+UP';
}
// Macro
const macro = byondMacros[byondKeyCodeIdent];
if (macro) {
return Byond.command(macro);
}
// KeyDown
if (key.isDown() && !keyState[byondKeyCode]) {
keyState[byondKeyCode] = true;
const command = keyPassthroughConfig.verbParamsFn(
keyPassthroughConfig.keyDownVerb,
byondKeyCode,
);
return Byond.command(command);
}
// KeyUp
if (key.isUp() && keyState[byondKeyCode]) {
keyState[byondKeyCode] = false;
const command = keyPassthroughConfig.verbParamsFn(
keyPassthroughConfig.keyUpVerb,
byondKeyCode,
);
return Byond.command(command);
}
}
/**
* Acquires a lock on the hotkey, which prevents it from being
* passed through to BYOND.
*/
export function acquireHotKey(keyCode: number) {
hotKeysAcquired.push(keyCode);
}
/**
* Makes the hotkey available to BYOND again.
*/
export function releaseHotKey(keyCode: number) {
const index = hotKeysAcquired.indexOf(keyCode);
if (index >= 0) {
hotKeysAcquired.splice(index, 1);
}
}
export function releaseHeldKeys() {
for (const byondKeyCode in keyState) {
if (keyState[byondKeyCode]) {
keyState[byondKeyCode] = false;
Byond.command(
keyPassthroughConfig.verbParamsFn(
keyPassthroughConfig.keyUpVerb,
byondKeyCode,
),
);
}
}
}
type ByondSkinMacro = {
command: string;
name: string;
};
let keyPassthroughConfig: KeyPassthroughConfig = {
keyDownVerb: 'KeyDown',
keyUpVerb: 'KeyUp',
verbParamsFn: (verb, keyCode) => `${verb} "${keyCode}"`,
};
export type KeyPassthroughConfig = {
keyUpVerb: string;
keyDownVerb: string;
verbParamsFn: (verb: string, keyCode: string) => string;
};
export function setupHotKeys(config?: KeyPassthroughConfig) {
if (config) {
keyPassthroughConfig = config;
}
// Read macros
Byond.winget(null, 'macros').then((data: string) => {
const separated = data.split(';');
const promises: Promise<any>[] = [];
for (const set of separated) {
promises.push(Byond.winget(set + '.*'));
}
Promise.all(promises).then((sets: Record<string, string>[]) => {
// Group each macro by ref
const groupedByRef: Record<string, ByondSkinMacro> = {};
for (const set of sets) {
for (const key of Object.keys(set)) {
const keyPath = key.split('.');
const ref = keyPath[1];
const prop = keyPath[2];
if (ref && prop) {
// This piece of code imperatively adds each property to a
// ByondSkinMacro object in the order we meet it, which is hard
// to express safely in typescript.
if (!groupedByRef[ref]) {
groupedByRef[ref] = {} as any;
}
groupedByRef[ref][prop] = set[key];
}
}
}
// Insert macros
const escapedQuotRegex = /\\"/g;
function unEscape(str: string) {
return str.substring(1, str.length - 1).replace(escapedQuotRegex, '"');
}
for (const ref of Object.keys(groupedByRef)) {
const macro = groupedByRef[ref];
const byondKeyName = unEscape(macro.name);
byondMacros[byondKeyName] = unEscape(macro.command);
}
logger.log(byondMacros);
});
});
// Setup event handlers
globalEvents.on('window-blur', () => {
releaseHeldKeys();
});
globalEvents.on('input-focus', () => {
releaseHeldKeys();
});
startKeyPassthrough();
}
export function startKeyPassthrough() {
globalEvents.on('key', keyEvent);
}
export function stopKeyPassthrough() {
globalEvents.off('key', keyEvent);
}
function keyEvent(key: KeyEvent) {
for (const keyListener of keyListeners) {
keyListener(key);
}
handlePassthrough(key);
}
/**
* Registers for any key events, such as key down or key up.
* This should be preferred over directly connecting to keydown/keyup
* as it lets tgui prevent the key from reaching BYOND.
*
* If using in a component, prefer KeyListener, which automatically handles
* stopping listening when unmounting.
*
* @param callback The function to call whenever a key event occurs
* @returns A callback to stop listening
*/
export function listenForKeyEvents(callback: (key: KeyEvent) => void) {
keyListeners.push(callback);
let removed = false;
return () => {
if (removed) {
return;
}
removed = true;
keyListeners.splice(keyListeners.indexOf(callback), 1);
};
}

View File

@@ -33,11 +33,11 @@ import './styles/themes/algae.scss';
import { perf } from 'common/perf';
import { setupGlobalEvents } from 'tgui-core/events';
import { setupHotKeys } from 'tgui-core/hotkeys';
import { setupHotReloading } from 'tgui-dev-server/link/client';
import { App } from './App';
import { setGlobalStore } from './backend';
import { setupHotKeys } from './hotkeys';
import { captureExternalLinks } from './links';
import { render } from './renderer';
import { configureStore } from './store';

View File

@@ -0,0 +1,26 @@
import { Box } from 'tgui-core/components';
import { createSearch } from 'tgui-core/string';
import { LootBox } from './LootBox';
import type { SearchGroup, SearchItem } from './types';
type Props = {
contents: Record<string, SearchItem[]>;
searchText: string;
};
export function GroupedContents(props: Props) {
const { contents, searchText } = props;
const filteredContents: SearchGroup[] = Object.entries(contents)
.filter(createSearch(searchText, ([_, items]) => items[0].name))
.map(([_, items]) => ({ amount: items.length, item: items[0] }));
return (
<Box m={-0.5}>
{filteredContents.map((group) => (
<LootBox key={group.item.name} group={group} />
))}
</Box>
);
}

View File

@@ -0,0 +1,44 @@
import { DmIcon, Icon, Image } from 'tgui-core/components';
import type { SearchItem } from './types';
type Props = {
item: SearchItem;
size: Size;
};
type Size = {
height: number;
width: number;
};
export function IconDisplay(props: Props) {
const {
item: { icon, icon_state },
size: { height, width },
} = props;
const fallback = <Icon name="spinner" size={1.5} spin color="gray" />;
if (!icon) {
return fallback;
}
if (icon === 'n/a') {
return <Icon name="dumpster-fire" size={1.5} color="gray" />;
}
if (icon_state) {
return (
<DmIcon
fallback={fallback}
icon={icon}
icon_state={icon_state}
height={height}
width={width}
/>
);
}
return <Image fixErrors src={icon} height={3} width={3} />;
}

View File

@@ -0,0 +1,78 @@
import { useBackend } from 'tgui/backend';
import { Button, Stack } from 'tgui-core/components';
import type { BooleanLike } from 'tgui-core/react';
import { capitalizeFirst } from 'tgui-core/string';
import { IconDisplay } from './IconDisplay';
import type { SearchGroup, SearchItem } from './types';
type Data = {
is_blind: BooleanLike;
};
type Props =
| {
item: SearchItem;
}
| {
group: SearchGroup;
};
export function LootBox(props: Props) {
const { act, data } = useBackend<Data>();
const { is_blind } = data;
let amount = 0;
let item: SearchItem;
if ('group' in props) {
amount = props.group.amount;
item = props.group.item;
} else {
item = props.item;
}
const name = !item.name ? '???' : capitalizeFirst(item.name);
const content = (
<Button
p={0}
fluid
color="transparent"
onClick={(event) =>
act('grab', {
alt: event.altKey,
ctrl: event.ctrlKey,
ref: item.ref,
shift: event.shiftKey,
})
}
onContextMenu={(event) => {
event.preventDefault();
act('grab', {
right: true,
ref: item.ref,
});
}}
>
<Stack>
<Stack.Item mb={-1} minWidth={'36px'} minHeight={'42px'}>
<IconDisplay item={item} size={{ height: 3, width: 3 }} />
</Stack.Item>
<Stack.Item
lineHeight="34px"
overflow="hidden"
style={{ textOverflow: 'ellipsis' }}
>
{!is_blind && name}
</Stack.Item>
<Stack.Item lineHeight="34px" pr={1}>
{amount > 1 && 'x' + amount}
</Stack.Item>
</Stack>
</Button>
);
if (is_blind) return content;
return content;
}

View File

@@ -0,0 +1,26 @@
import { Box } from 'tgui-core/components';
import { createSearch } from 'tgui-core/string';
import { LootBox } from './LootBox';
import type { SearchItem } from './types';
type Props = {
contents: SearchItem[];
searchText: string;
};
export function RawContents(props: Props) {
const { contents, searchText } = props;
const filteredContents = contents.filter(
createSearch(searchText, (item: SearchItem) => item.name),
);
return (
<Box m={-0.5}>
{filteredContents.map((item) => (
<LootBox key={item.ref} item={item} />
))}
</Box>
);
}

View File

@@ -0,0 +1,108 @@
import { useState } from 'react';
import { useMemo } from 'react';
import { useBackend } from 'tgui/backend';
import { Window } from 'tgui/layouts';
import { Button, Input, Section, Stack } from 'tgui-core/components';
import { isEscape } from 'tgui-core/keys';
import { clamp } from 'tgui-core/math';
import type { BooleanLike } from 'tgui-core/react';
import { GroupedContents } from './GroupedContents';
import { RawContents } from './RawContents';
import type { SearchItem } from './types';
type Data = {
contents: SearchItem[];
searching: BooleanLike;
};
export function LootPanel(props) {
const { act, data } = useBackend<Data>();
const { contents = [], searching } = data;
// limitations: items with different stack counts, charges etc.
const contentsByPathName = useMemo(() => {
const acc: Record<string, SearchItem[]> = {};
for (let i = 0; i < contents.length; i++) {
const item = contents[i];
if (item.path) {
if (!acc[item.path + item.name]) {
acc[item.path + item.name] = [];
}
acc[item.path + item.name].push(item);
} else {
acc[item.ref] = [item];
}
}
return acc;
}, [contents]);
const [grouping, setGrouping] = useState(true);
const [searchText, setSearchText] = useState('');
const headerHeight = 38;
const itemHeight = 38;
const minHeight = headerHeight + itemHeight;
const maxHeight = headerHeight + itemHeight * 10;
const height: number = clamp(
headerHeight +
(!grouping ? contents.length : Object.keys(contentsByPathName).length) *
itemHeight,
minHeight,
maxHeight,
);
return (
<Window
width={350}
height={height}
buttons={
<Stack align="center">
<Input
onChange={setSearchText}
placeholder="Search items..."
value={searchText}
/>
<Button
m={0}
icon={grouping ? 'layer-group' : 'object-ungroup'}
selected={grouping}
onClick={() => setGrouping(!grouping)}
tooltip="Toggle Grouping"
/>
<Button
icon="sync"
onClick={() => act('refresh')}
tooltip="Refresh"
/>
<Button
icon="circle-question"
tooltip="If you need keybinds to work while this menu is focused, hit Ctrl+R."
/>
</Stack>
}
>
<Window.Content
fitted
scrollable={height === maxHeight}
onKeyDown={(event) => {
if (isEscape(event.key)) {
Byond.sendMessage('close');
}
}}
>
<Section>
{grouping ? (
<GroupedContents
contents={contentsByPathName}
searchText={searchText}
/>
) : (
<RawContents contents={contents} searchText={searchText} />
)}
</Section>
</Window.Content>
</Window>
);
}

View File

@@ -0,0 +1,13 @@
export type SearchItem = {
name: string;
path: string;
ref: string;
} & Partial<{
icon: string;
icon_state: string;
}>;
export type SearchGroup = {
amount: number;
item: SearchItem;
};

View File

@@ -3032,6 +3032,13 @@
#include "code\modules\looking_glass\lg_console.dm"
#include "code\modules\looking_glass\lg_imageholder.dm"
#include "code\modules\looking_glass\lg_turfs.dm"
#include "code\modules\lootpanel\_lootpanel.dm"
#include "code\modules\lootpanel\contents.dm"
#include "code\modules\lootpanel\handlers.dm"
#include "code\modules\lootpanel\misc.dm"
#include "code\modules\lootpanel\search_object.dm"
#include "code\modules\lootpanel\ss_looting.dm"
#include "code\modules\lootpanel\ui.dm"
#include "code\modules\lore_codex\codex.dm"
#include "code\modules\lore_codex\pages.dm"
#include "code\modules\lore_codex\lore_data_yw\important_locations.dm"