RPG Loot: Revisited & READY (#82533)

## About The Pull Request
Revival of #72881

A new alt click window with a tarkov-y loading spinner. Replaces the
object item window in stat panel.

## Videos
<details>
<summary>vids</summary>

toggleable grouping:

![syAA5zf6RK](https://github.com/tgstation/tgstation/assets/42397676/c89b372d-29f6-4ebe-895d-f73bbdc41c19)

now lists the floor as first obj:

![abc](https://github.com/tgstation/tgstation/assets/42397676/cd8dc962-2ac7-41bf-a5d3-b9e926116b06)

in action:

![dreamseeker_IkrPKt2QZt](https://github.com/tgstation/tgstation/assets/42397676/1f990aa0-60f0-47e7-9d93-b63e35d05273)

</details>

## features:
- search by name
- 515 image generator is much faster than alt click menu
- opening a gargantuan amount of items shouldnt freeze your screen
- groups similar items together in stacks by default, toggleable
- shows tile as first item
- <kbd>Shift</kbd> and <kbd>Ctrl</kbd> compatible with LMB
🖱️
- RMB points points at items (sry i could not get MMB working)
- key <kbd>Esc</kbd> to exit the window.


For devs:
- A new image generation tech.
- An error refetch mechanic to the Image component
- It does not "smart track" the items being added to the pile, just
reopen or refresh. This was a design decision.

## Why It's Good For The Game
Honestly I just dislike the stat panel

Fixes #53824

Fixes 

![image](https://github.com/tgstation/tgstation/assets/42397676/0e50faab-7d4d-4bf7-8c5b-4ac28547bfbd)

## Changelog
🆑
add: Added a loot window for alt-clicking tiles.
del: Removed the item browser from the stat panel.
/🆑

---------

Co-authored-by: Zephyr <12817816+ZephyrTFA@users.noreply.github.com>
Co-authored-by: AnturK <AnturK@users.noreply.github.com>
Co-authored-by: LemonInTheDark <58055496+LemonInTheDark@users.noreply.github.com>
This commit is contained in:
Jeremiah
2024-04-14 14:35:03 -07:00
committed by GitHub
parent 0cd28f939c
commit d554ab7766
44 changed files with 875 additions and 254 deletions
+1
View File
@@ -185,6 +185,7 @@
#define INIT_ORDER_MINOR_MAPPING -40
#define INIT_ORDER_PATH -50
#define INIT_ORDER_EXPLOSIONS -69
#define INIT_ORDER_LOOT -70
#define INIT_ORDER_STATPANELS -97
#define INIT_ORDER_BAN_CACHE -98
#define INIT_ORDER_INIT_PROFILER -99 //Near the end, logs the costs of initialize
+3
View File
@@ -1114,4 +1114,7 @@ Remember to update _globalvars/traits.dm if you're adding/removing/renaming trai
/// Trait applied to objects and mobs that can attack a boulder and break it down. (See /obj/item/boulder/manual_process())
#define TRAIT_BOULDER_BREAKER "boulder_breaker"
/// Prevents the affected object from opening a loot window via alt click. See atom/AltClick()
#define TRAIT_ALT_CLICK_BLOCKER "no_alt_click"
// END TRAIT DEFINES
+1
View File
@@ -8,6 +8,7 @@
GLOBAL_LIST_INIT(traits_by_type, list(
/atom = list(
"TRAIT_AI_PAUSED" = TRAIT_AI_PAUSED,
"TRAIT_ALT_CLICK_BLOCKER" = TRAIT_ALT_CLICK_BLOCKER,
"TRAIT_BANNED_FROM_CARGO_SHUTTLE" = TRAIT_BANNED_FROM_CARGO_SHUTTLE,
"TRAIT_BEING_SHOCKED" = TRAIT_BEING_SHOCKED,
"TRAIT_COMMISSIONED" = TRAIT_COMMISSIONED,
+32 -12
View File
@@ -395,14 +395,40 @@
return
A.AltClick(src)
/**
* 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(mob/user)
if(!user.can_interact_with(src))
return FALSE
if(SEND_SIGNAL(src, COMSIG_CLICK_ALT, user) & COMPONENT_CANCEL_CLICK_ALT)
return
var/turf/T = get_turf(src)
if(T && (isturf(loc) || isturf(src)) && user.TurfAdjacent(T) && !HAS_TRAIT(user, TRAIT_MOVE_VENTCRAWLING))
user.set_listed_turf(T)
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
if(HAS_TRAIT(user, TRAIT_MOVE_VENTCRAWLING))
return FALSE
var/datum/lootpanel/panel = user.client?.loot_panel
if(isnull(panel))
return FALSE
panel.open(tile)
return TRUE
///The base proc of when something is right clicked on when alt is held - generally use alt_click_secondary instead
/atom/proc/alt_click_on_secondary(atom/A)
@@ -421,14 +447,8 @@
user.client.toggle_tag_datum(src)
return
/// Use this instead of [/mob/proc/AltClickOn] where you only want turf content listing without additional atom alt-click interaction
/atom/proc/AltClickNoInteract(mob/user, atom/A)
var/turf/T = get_turf(A)
if(T && user.TurfAdjacent(T))
user.set_listed_turf(T)
/mob/proc/TurfAdjacent(turf/T)
return T.Adjacent(src)
/mob/proc/TurfAdjacent(turf/tile)
return tile.Adjacent(src)
/**
* Control+Shift click
+1 -1
View File
@@ -32,7 +32,7 @@
MiddleClickOn(A, params)
return
if(LAZYACCESS(modifiers, ALT_CLICK))
AltClickNoInteract(src, A)
A.AltClick(src)
return
if(LAZYACCESS(modifiers, CTRL_CLICK))
CtrlClickOn(A)
@@ -1,24 +0,0 @@
PROCESSING_SUBSYSTEM_DEF(obj_tab_items)
name = "Obj Tab Items"
flags = SS_NO_INIT
runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT
wait = 0.1 SECONDS
// I know this is mostly copypasta, but I want to change the processing logic
// Sorry bestie :(
/datum/controller/subsystem/processing/obj_tab_items/fire(resumed = FALSE)
if (!resumed)
currentrun = processing.Copy()
//cache for sanic speed (lists are references anyways)
var/list/current_run = currentrun
while(current_run.len)
var/datum/thing = current_run[current_run.len]
if(QDELETED(thing))
processing -= thing
else if(thing.process(wait * 0.1) == PROCESS_KILL)
// fully stop so that a future START_PROCESSING will work
STOP_PROCESSING(src, thing)
if (MC_TICK_CHECK)
return
current_run.len--
-183
View File
@@ -88,12 +88,6 @@ SUBSYSTEM_DEF(statpanels)
if(update_actions && num_fires % default_wait == 0)
set_action_tabs(target, target_mob)
// Handle the examined turf of the stat panel, if it's been long enough, or if we've generated new images for it
var/turf/listed_turf = target_mob?.listed_turf
if(listed_turf && num_fires % default_wait == 0)
if(target.stat_tab == listed_turf.name || !(listed_turf.name in target.panel_tabs))
set_turf_examine_tab(target, target_mob)
if(MC_TICK_CHECK)
return
@@ -166,71 +160,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)
var/list/overrides = list()
for(var/image/target_image as anything in target.images)
if(!target_image.loc || target_image.loc.loc != target_mob.listed_turf || !target_image.override)
continue
overrides += target_image.loc
var/list/atoms_to_display = list(target_mob.listed_turf)
for(var/atom/movable/turf_content as anything in target_mob.listed_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
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)
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_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(
@@ -272,16 +201,6 @@ SUBSYSTEM_DEF(statpanels)
set_action_tabs(target, target_mob)
return TRUE
// Handle turfs
if(target_mob?.listed_turf)
if(!target_mob.TurfAdjacent(target_mob.listed_turf))
target_mob.set_listed_turf(null)
else if(target.stat_tab == target_mob?.listed_turf.name || !(target_mob?.listed_turf.name in target.panel_tabs))
set_turf_examine_tab(target, target_mob)
return TRUE
if(!target.holder)
return FALSE
@@ -301,105 +220,3 @@ SUBSYSTEM_DEF(statpanels)
/// Stat panel window declaration
/client/var/datum/tgui_window/stat_panel
/// 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
/// Are we currently tracking a turf?
var/actively_tracking = FALSE
/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]
var/generated_string
if(ismob(thing) || length(thing.overlays) > 2)
generated_string = costly_icon2html(thing, parent, sourceonly=TRUE)
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()
if(actively_tracking)
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)
actively_tracking = TRUE
/datum/object_window_info/proc/stop_turf_tracking()
qdel(GetComponent(/datum/component/connect_mob_behalf))
actively_tracking = FALSE
/datum/object_window_info/proc/on_mob_move(mob/source)
SIGNAL_HANDLER
var/turf/listed = source.listed_turf
if(!listed || !source.TurfAdjacent(listed))
source.set_listed_turf(null)
/datum/object_window_info/proc/on_mob_logout(mob/source)
SIGNAL_HANDLER
on_mob_move(parent.mob)
/// 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)
listed_turf = new_turf
if(!client)
return
if(!client.obj_window)
client.obj_window = new(client)
if(listed_turf)
client.stat_panel.send_message("create_listedturf", listed_turf.name)
client.obj_window.start_turf_tracking()
else
client.stat_panel.send_message("remove_listedturf")
client.obj_window.stop_turf_tracking()
+1
View File
@@ -89,6 +89,7 @@ GLOBAL_LIST_EMPTY(GPS_list)
SIGNAL_HANDLER
toggletracking(user)
return COMPONENT_CANCEL_CLICK_ALT
///Toggles the tracking for the gps
/datum/component/gps/item/proc/toggletracking(mob/user)
+4
View File
@@ -26,6 +26,8 @@
RegisterSignal(parent, COMSIG_CLICK_ALT_SECONDARY, PROC_REF(rotate_right))
RegisterSignal(parent, COMSIG_ATOM_EXAMINE, PROC_REF(ExamineMessage))
RegisterSignal(parent, COMSIG_ATOM_REQUESTING_CONTEXT_FROM_ITEM, PROC_REF(on_requesting_context_from_item))
ADD_TRAIT(parent, TRAIT_ALT_CLICK_BLOCKER, REF(src))
return ..()
/datum/component/simple_rotation/PostTransfer()
@@ -41,6 +43,8 @@
COMSIG_ATOM_EXAMINE,
COMSIG_ATOM_REQUESTING_CONTEXT_FROM_ITEM,
))
REMOVE_TRAIT(parent, TRAIT_ALT_CLICK_BLOCKER, REF(src))
return ..()
/datum/component/simple_rotation/Destroy()
@@ -53,6 +53,7 @@
/obj/item/grenade/Initialize(mapload)
. = ..()
ADD_TRAIT(src, TRAIT_ODD_CUSTOMIZABLE_FOOD_INGREDIENT, type)
ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, REF(src))
RegisterSignal(src, COMSIG_ITEM_USED_AS_INGREDIENT, PROC_REF(on_used_as_ingredient))
/obj/item/grenade/suicide_act(mob/living/carbon/user)
@@ -0,0 +1,28 @@
/// Maps icon names to ref values
/datum/asset/json/icon_ref_map
name = "icon_ref_map"
early = TRUE
/datum/asset/json/icon_ref_map/generate()
var/list/data = list() //"icons/obj/drinks.dmi" => "[0xc000020]"
//var/start = "0xc000000"
var/value = 0
while(TRUE)
value += 1
var/ref = "\[0xc[num2text(value,6,16)]\]"
var/mystery_meat = locate(ref)
if(isicon(mystery_meat))
if(!isfile(mystery_meat)) // Ignore the runtime icons for now
continue
var/path = get_icon_dmi_path(mystery_meat) //Try to get the icon path
if(path)
data[path] = ref
else if(mystery_meat)
continue; //Some other non-icon resource, ogg/json/whatever
else //Out of resources end this, could also try to end this earlier as soon as runtime generated icons appear but eh
break;
return data
@@ -82,11 +82,15 @@
/// asset_list - A list of asset filenames to be sent to the client. Can optionally be assoicated with the asset's asset_cache_item datum.
/// Returns TRUE if any assets were sent.
/datum/asset_transport/proc/send_assets(client/client, list/asset_list)
#if defined(UNIT_TESTS)
return
#endif
if (!istype(client))
if (ismob(client))
var/mob/M = client
if (M.client)
client = M.client
var/mob/our_mob = client
if (our_mob.client)
client = our_mob.client
else //no stacktrace because this will mainly happen because the client went away
return
else
@@ -22,6 +22,7 @@ Passive gate is similar to the regular pump except:
/obj/machinery/atmospherics/components/binary/passive_gate/Initialize(mapload)
. = ..()
ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, REF(src))
register_context()
/obj/machinery/atmospherics/components/binary/passive_gate/add_context(atom/source, list/context, obj/item/held_item, mob/user)
@@ -14,6 +14,7 @@
/obj/machinery/atmospherics/components/binary/pressure_valve/Initialize(mapload)
. = ..()
ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, REF(src))
register_context()
/obj/machinery/atmospherics/components/binary/pressure_valve/add_context(atom/source, list/context, obj/item/held_item, mob/user)
@@ -27,6 +27,7 @@
AddComponent(/datum/component/usb_port, list(
/obj/item/circuit_component/atmos_pump,
))
ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, REF(src))
register_context()
/obj/machinery/atmospherics/components/binary/pump/add_context(atom/source, list/context, obj/item/held_item, mob/user)
@@ -19,6 +19,7 @@
/obj/machinery/atmospherics/components/binary/temperature_gate/Initialize(mapload)
. = ..()
ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, REF(src))
register_context()
/obj/machinery/atmospherics/components/binary/temperature_gate/add_context(atom/source, list/context, obj/item/held_item, mob/user)
@@ -14,6 +14,7 @@
/obj/machinery/atmospherics/components/binary/temperature_pump/Initialize(mapload)
. = ..()
ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, REF(src))
register_context()
/obj/machinery/atmospherics/components/binary/temperature_pump/add_context(atom/source, list/context, obj/item/held_item, mob/user)
@@ -31,6 +31,7 @@
AddComponent(/datum/component/usb_port, list(
/obj/item/circuit_component/atmos_volume_pump,
))
ADD_TRAIT(src, TRAIT_ALT_CLICK_BLOCKER, REF(src))
register_context()
/obj/machinery/atmospherics/components/binary/volume_pump/CtrlClick(mob/user)
+3 -2
View File
@@ -194,8 +194,6 @@
var/list/spell_tabs = list()
///A lazy list of atoms we've examined in the last RECENT_EXAMINE_MAX_WINDOW (default 2) seconds, so that we will call [/atom/proc/examine_more] instead of [/atom/proc/examine] on them when examining
var/list/recent_examines
///Our object window datum. It stores info about and handles behavior for the object tab
var/datum/object_window_info/obj_window
var/list/parallax_layers
var/list/parallax_layers_cached
@@ -266,3 +264,6 @@
/// Does this client have typing indicators enabled?
var/typing_indicators = FALSE
/// Loot panel for the client
var/datum/lootpanel/loot_panel
+3 -2
View File
@@ -540,6 +540,8 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if (!interviewee)
initialize_menus()
loot_panel = new(src)
view_size = new(src, getScreenSize(prefs.read_preference(/datum/preference/toggle/widescreen)))
view_size.resetFormat()
view_size.setZoomMode()
@@ -580,8 +582,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
SSserver_maint.UpdateHubStatus()
if(credits)
QDEL_LIST(credits)
if(obj_window)
QDEL_NULL(obj_window)
if(holder)
adminGreet(1)
holder.owner = null
@@ -611,6 +611,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
QDEL_NULL(view_size)
QDEL_NULL(void)
QDEL_NULL(tooltips)
QDEL_NULL(loot_panel)
seen_messages = null
Master.UpdateTickRate()
..() //Even though we're going to be hard deleted there are still some things that want to know the destroy is happening
+78
View File
@@ -0,0 +1,78 @@
/**
* ## 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/ui_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/ui_close(mob/user)
. = ..()
source_turf = null
reset_contents()
/datum/lootpanel/ui_data(mob/user)
var/list/data = list()
data["contents"] = get_contents()
data["searching"] = length(to_image)
return data
/datum/lootpanel/ui_status(mob/user, datum/ui_state/state)
if(!source_turf.Adjacent(user))
return UI_CLOSE
if(user.incapacitated())
return UI_DISABLED
return UI_INTERACTIVE
/datum/lootpanel/ui_act(action, list/params)
. = ..()
if(.)
return
switch(action)
if("grab")
return grab(usr, params)
if("refresh")
return populate_contents()
return FALSE
+49
View File
@@ -0,0 +1,49 @@
/// Adds the item to contents and to_image (if needed)
/datum/lootpanel/proc/add_to_index(datum/search_object/index)
RegisterSignal(index, COMSIG_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(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_QDELETING)
qdel(index)
+19
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()
+48
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, examine_block(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()
ui_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)
+84
View File
@@ -0,0 +1,84 @@
/**
* ## 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
RegisterSignals(item, list(
COMSIG_ITEM_PICKUP,
COMSIG_MOVABLE_MOVED,
COMSIG_QDELETING,
), PROC_REF(on_item_moved))
// Icon generation conditions //////////////
// Condition 1: Icon is complex
if(ismob(item) || length(item.overlays) > 2)
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))
return
icon = "[item.icon]"
icon_state = item.icon_state
/datum/search_object/Destroy(force)
item = null
return ..()
/// Generates the icon for the search object. This is the expensive part.
/datum/search_object/proc/generate_icon(client/owner)
if(ismob(item) || length(item.overlays) > 2)
icon = costly_icon2html(item, owner, sourceonly = TRUE)
else // our pre 515.1635 fallback for normal items
icon = 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
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(src, GLOBAL_PROC_REF(qdel), src)
+39
View File
@@ -0,0 +1,39 @@
/// Queues image generation for search objects without icons
SUBSYSTEM_DEF(looting)
name = "Loot Icon Generation"
init_order = INIT_ORDER_LOOT
priority = FIRE_PRIORITY_PROCESS
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--
+46
View File
@@ -0,0 +1,46 @@
/// 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
if(!source_turf.Adjacent(user)) // Source tile is no longer valid
reset_contents()
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;"
user.ClickOn(thing, modifiers)
return TRUE
@@ -204,7 +204,7 @@
ShiftClickOn(A)
return
if(LAZYACCESS(modifiers, ALT_CLICK))
AltClickNoInteract(src, A)
A.AltClick(src)
return
if(LAZYACCESS(modifiers, RIGHT_CLICK))
ranged_secondary_attack(A, modifiers)
+2
View File
@@ -118,6 +118,8 @@
/datum/asset/simple/namespaced/fontawesome))
flush_queue |= window.send_asset(get_asset_datum(
/datum/asset/simple/namespaced/tgfont))
flush_queue |= window.send_asset(get_asset_datum(
/datum/asset/json/icon_ref_map))
for(var/datum/asset/asset in src_object.ui_assets(user))
flush_queue |= window.send_asset(asset)
if (flush_queue)
+1
View File
@@ -168,6 +168,7 @@
#include "ling_decap.dm"
#include "liver.dm"
#include "load_map_security.dm"
#include "lootpanel.dm"
#include "lungs.dm"
#include "machine_disassembly.dm"
#include "mafia.dm"
+34
View File
@@ -0,0 +1,34 @@
/datum/unit_test/lootpanel
abstract_type = /datum/unit_test/lootpanel
/datum/unit_test/lootpanel/contents/Run()
var/datum/client_interface/mock_client = new()
var/datum/lootpanel/panel = new(mock_client)
var/mob/living/carbon/human/labrat = allocate(/mob/living/carbon/human/consistent)
mock_client.mob = labrat
var/turf/one_over = locate(run_loc_floor_bottom_left.x + 1, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z)
var/obj/item/storage/toolbox/box = allocate(/obj/item/storage/toolbox, one_over)
panel.open(one_over)
TEST_ASSERT_EQUAL(length(panel.contents), 2, "Contents should populate on open")
TEST_ASSERT_EQUAL(length(panel.to_image), 2, "to_image should've populated (unit testing)")
TEST_ASSERT_EQUAL(panel.contents[1].item, one_over, "First item should be the source turf")
var/datum/search_object/searchable = panel.contents[2]
TEST_ASSERT_EQUAL(searchable.item, box, "Second item should be the box")
qdel(box)
TEST_ASSERT_EQUAL(length(panel.contents), 1, "Contents should update on searchobj deleted")
TEST_ASSERT_EQUAL(length(panel.to_image), 1, "to_image should update on searchobj deleted")
var/obj/item/storage/toolbox/new_box = allocate(/obj/item/storage/toolbox, one_over)
TEST_ASSERT_EQUAL(length(panel.contents), 1, "Contents shouldn't update, we're dumb")
TEST_ASSERT_EQUAL(length(panel.to_image), 1, "to_image shouldn't update, we're dumb")
panel.populate_contents() // this also calls reset_contents bc length(contents)
TEST_ASSERT_EQUAL(length(panel.contents), 2, "Contents should repopulate with the new toolbox")
panel.populate_contents()
TEST_ASSERT_EQUAL(length(panel.contents), 2, "Panel shouldnt dupe searchables if reopened")
mock_client.mob = null
+8 -1
View File
@@ -751,7 +751,6 @@
#include "code\controllers\subsystem\processing\greyscale.dm"
#include "code\controllers\subsystem\processing\instruments.dm"
#include "code\controllers\subsystem\processing\obj.dm"
#include "code\controllers\subsystem\processing\obj_tab_items.dm"
#include "code\controllers\subsystem\processing\plumbing.dm"
#include "code\controllers\subsystem\processing\processing.dm"
#include "code\controllers\subsystem\processing\projectiles.dm"
@@ -3307,6 +3306,7 @@
#include "code\modules\asset_cache\assets\fontawesome.dm"
#include "code\modules\asset_cache\assets\genetics.dm"
#include "code\modules\asset_cache\assets\headers.dm"
#include "code\modules\asset_cache\assets\icon_ref_map.dm"
#include "code\modules\asset_cache\assets\inventory.dm"
#include "code\modules\asset_cache\assets\irv.dm"
#include "code\modules\asset_cache\assets\jquery.dm"
@@ -4390,6 +4390,13 @@
#include "code\modules\logging\categories\log_category_silo.dm"
#include "code\modules\logging\categories\log_category_target_zone_switch.dm"
#include "code\modules\logging\categories\log_category_uplink.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\mafia\_defines.dm"
#include "code\modules\mafia\controller.dm"
#include "code\modules\mafia\controller_ui.dm"
+72
View File
@@ -0,0 +1,72 @@
import { ReactNode, useEffect, useState } from 'react';
import { resolveAsset } from '../assets';
import { fetchRetry } from '../http';
import { BoxProps } from './Box';
import { Image } from './Image';
enum Direction {
NORTH = 1,
SOUTH = 2,
EAST = 4,
WEST = 8,
NORTHEAST = NORTH | EAST,
NORTHWEST = NORTH | WEST,
SOUTHEAST = SOUTH | EAST,
SOUTHWEST = SOUTH | WEST,
}
type Props = {
/** Required: The path of the icon */
icon: string;
/** Required: The state of the icon */
icon_state: string;
} & Partial<{
/** Facing direction. See direction enum. Default is South */
direction: Direction;
/** Fallback icon. */
fallback: ReactNode;
/** Frame number. Default is 1 */
frame: number;
/** Movement state. Default is false */
movement: boolean;
}> &
BoxProps;
let refMap: Record<string, string> | undefined;
export function DmIcon(props: Props) {
const {
className,
direction = Direction.SOUTH,
fallback,
frame = 1,
icon_state,
icon,
movement = false,
...rest
} = props;
const [iconRef, setIconRef] = useState('');
const query = `${iconRef}?state=${icon_state}&dir=${direction}&movement=${movement}&frame=${frame}`;
useEffect(() => {
async function fetchRefMap() {
const response = await fetchRetry(resolveAsset('icon_ref_map.json'));
const data = await response.json();
refMap = data;
setIconRef(data[icon]);
}
if (!refMap) {
fetchRefMap();
} else {
setIconRef(refMap[icon]);
}
}, []);
if (!iconRef) return fallback;
return <Image fixErrors src={query} {...rest} />;
}
+28 -15
View File
@@ -1,12 +1,14 @@
import { ReactNode } from 'react';
import { useRef } from 'react';
import { BoxProps, computeBoxProps } from './Box';
import { Tooltip } from './Tooltip';
type Props = Partial<{
fixBlur: boolean; // true is default, this is an ie thing
objectFit: 'contain' | 'cover'; // fill is default
tooltip: ReactNode;
/** True is default, this fixes an ie thing */
fixBlur: boolean;
/** False by default. Good if you're fetching images on UIs that do not auto update. This will attempt to fix the 'x' icon 5 times. */
fixErrors: boolean;
/** Fill is default. */
objectFit: 'contain' | 'cover';
}> &
IconUnion &
BoxProps;
@@ -22,16 +24,18 @@ type IconUnion =
src?: string;
};
const maxAttempts = 5;
/** Image component. Use this instead of Box as="img". */
export const Image = (props: Props) => {
export function Image(props: Props) {
const {
className,
fixBlur = true,
fixErrors = false,
objectFit = 'fill',
src,
tooltip,
...rest
} = props;
const attempts = useRef(0);
const computedProps = computeBoxProps(rest);
computedProps['style'] = {
@@ -40,11 +44,20 @@ export const Image = (props: Props) => {
objectFit,
};
let content = <img className={className} src={src} {...computedProps} />;
return (
<img
onError={(event) => {
if (fixErrors && attempts.current < maxAttempts) {
const imgElement = event.currentTarget;
if (tooltip) {
content = <Tooltip content={tooltip}>{content}</Tooltip>;
}
return content;
};
setTimeout(() => {
imgElement.src = `${src}?attempt=${attempts.current}`;
attempts.current++;
}, 1000);
}
}}
src={src}
{...computedProps}
/>
);
}
+1
View File
@@ -17,6 +17,7 @@ export { ColorBox } from './ColorBox';
export { Dialog } from './Dialog';
export { Dimmer } from './Dimmer';
export { Divider } from './Divider';
export { DmIcon } from './DmIcon';
export { DraggableControl } from './DraggableControl';
export { Dropdown } from './Dropdown';
export { FitText } from './FitText';
@@ -0,0 +1,47 @@
import { createSearch } from 'common/string';
import { useMemo } from 'react';
import { Flex } from '../../components';
import { LootBox } from './LootBox';
import { SearchGroup, SearchItem } from './types';
type Props = {
contents: SearchItem[];
searchText: string;
};
export function GroupedContents(props: Props) {
const { contents, searchText } = props;
// limitations: items with different stack counts, charges etc.
const contentsByPath = 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]) {
acc[item.path] = [];
}
acc[item.path].push(item);
} else {
acc[item.ref] = [item];
}
}
return acc;
}, [contents]);
const filteredContents: SearchGroup[] = Object.entries(contentsByPath)
.filter(createSearch(searchText, ([_, items]) => items[0].name))
.map(([_, items]) => ({ amount: items.length, item: items[0] }));
return (
<Flex wrap>
{filteredContents.map((group) => (
<Flex.Item key={group.item.name} m={1}>
<LootBox group={group} />
</Flex.Item>
))}
</Flex>
);
}
@@ -0,0 +1,24 @@
import { DmIcon, Icon, Image } from '../../components';
import { SearchItem } from './types';
type Props = {
item: SearchItem;
};
export function IconDisplay(props: Props) {
const {
item: { icon, icon_state },
} = props;
const fallback = <Icon name="spinner" size={2.4} spin color="gray" />;
if (!icon) {
return fallback;
}
if (icon_state) {
return <DmIcon fallback={fallback} icon={icon} icon_state={icon_state} />;
}
return <Image fixErrors src={icon} />;
}
@@ -0,0 +1,53 @@
import { capitalizeAll } from 'common/string';
import { useBackend } from '../../backend';
import { Tooltip } from '../../components';
import { IconDisplay } from './IconDisplay';
import { SearchGroup, SearchItem } from './types';
type Props =
| {
item: SearchItem;
}
| {
group: SearchGroup;
};
export function LootBox(props: Props) {
const { act } = useBackend();
let amount = 0;
let item: SearchItem;
if ('group' in props) {
amount = props.group.amount;
item = props.group.item;
} else {
item = props.item;
}
return (
<Tooltip content={capitalizeAll(item.name)}>
<div
className="SearchItem"
onClick={(event) =>
act('grab', {
ctrl: event.ctrlKey,
ref: item.ref,
shift: event.shiftKey,
})
}
onContextMenu={(event) => {
event.preventDefault();
act('grab', {
middle: true,
ref: item.ref,
shift: true,
});
}}
>
<IconDisplay item={item} />
{amount > 1 && <div className="SearchItem--amount">{amount}</div>}
</div>
</Tooltip>
);
}
@@ -0,0 +1,28 @@
import { createSearch } from 'common/string';
import { Flex } from '../../components';
import { LootBox } from './LootBox';
import { 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 (
<Flex wrap>
{filteredContents.map((item) => (
<Flex.Item key={item.ref} m={1}>
<LootBox item={item} />
</Flex.Item>
))}
</Flex>
);
}
@@ -0,0 +1,76 @@
import { KEY } from 'common/keys';
import { BooleanLike } from 'common/react';
import { useState } from 'react';
import { useBackend } from '../../backend';
import { Button, Input, Section, Stack } from '../../components';
import { Window } from '../../layouts';
import { GroupedContents } from './GroupedContents';
import { RawContents } from './RawContents';
import { SearchItem } from './types';
type Data = {
contents: SearchItem[];
searching: BooleanLike;
};
export function LootPanel(props) {
const { act, data } = useBackend<Data>();
const { contents = [], searching } = data;
const [grouping, setGrouping] = useState(true);
const [searchText, setSearchText] = useState('');
const total = contents.length ? contents.length - 1 : 0;
return (
<Window height={250} width={190} title={`Contents: ${total}`}>
<Window.Content
onKeyDown={(event) => {
if (event.key === KEY.Escape) {
Byond.sendMessage('close');
}
}}
>
<Section
fill
scrollable
title={
<Stack>
<Stack.Item grow>
<Input
autoFocus
fluid
onInput={(event, value) => setSearchText(value)}
placeholder="Search"
/>
</Stack.Item>
<Stack.Item>
<Button
icon={grouping ? 'layer-group' : 'object-ungroup'}
selected={grouping}
onClick={() => setGrouping(!grouping)}
tooltip="Toggle Grouping"
/>
</Stack.Item>
<Stack.Item>
<Button
disabled={!!searching}
icon="sync"
onClick={() => act('refresh')}
tooltip="Refresh"
/>
</Stack.Item>
</Stack>
}
>
{grouping ? (
<GroupedContents contents={contents} searchText={searchText} />
) : (
<RawContents contents={contents} searchText={searchText} />
)}
</Section>
</Window.Content>
</Window>
);
}
@@ -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;
};
@@ -3,7 +3,7 @@ import { createSearch } from 'common/string';
import { useState } from 'react';
import { useBackend } from '../backend';
import { Button, Image, Input, Section } from '../components';
import { Button, Image, Input, Section, Tooltip } from '../components';
import { NtosWindow } from '../layouts';
type Data = {
@@ -44,15 +44,15 @@ export const NtosEmojipedia = (props) => {
}
>
{filteredEmojis.map((emoji) => (
<Image
key={emoji.name}
m={0}
className={classes(['emojipedia16x16', emoji.name])}
tooltip={emoji.name}
onClick={() => {
copyText(emoji.name);
}}
/>
<Tooltip key={emoji.name} content={emoji.name}>
<Image
m={0}
className={classes(['emojipedia16x16', emoji.name])}
onClick={() => {
copyText(emoji.name);
}}
/>
</Tooltip>
))}
</Section>
</NtosWindow.Content>
@@ -0,0 +1,22 @@
@use '../colors.scss';
.SearchItem {
align-items: center;
background: black;
border: thin solid #212121;
display: flex;
height: 3rem;
justify-content: center;
position: relative;
width: 3rem;
margin-bottom: 0;
}
.SearchItem--amount {
bottom: -1rem;
color: colors.$teal;
font-family: 'Roboto', sans-serif;
font-size: 1.5rem;
position: absolute;
right: -4px;
}
+1
View File
@@ -38,6 +38,7 @@
@include meta.load-css('./components/NumberInput.scss');
@include meta.load-css('./components/ProgressBar.scss');
@include meta.load-css('./components/RoundGauge.scss');
@include meta.load-css('./components/SearchItem.scss');
@include meta.load-css('./components/Section.scss');
@include meta.load-css('./components/Slider.scss');
@include meta.load-css('./components/Stack.scss');