[MIRROR] Contextual tutorials for swapping hands and dropping items [MDB IGNORE] (#18604)

* Contextual tutorials for swapping hands and dropping items

* Fixes

* Oops

Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com>
Co-authored-by: Useroth <37159550+Useroth@users.noreply.github.com>
This commit is contained in:
SkyratBot
2023-01-10 14:26:08 +01:00
committed by GitHub
parent e046e8e924
commit 509b89f45c
31 changed files with 764 additions and 19 deletions
+257
View File
@@ -0,0 +1,257 @@
/// The base for a contextual tutorial.
/// In order to give a tutorial to someone, use `SStutorials.suggest_tutorial(user, /datum/tutorial/subtype)`
/datum/tutorial
/// If set, any account who started playing before this date will not be given this tutorial.
/// Date is in YYYY-MM-DD format.
var/grandfather_date
/// The mob we are giving the tutorial to
VAR_PROTECTED/mob/user
VAR_PRIVATE/atom/movable/screen/tutorial_instruction/instruction_screen
/datum/tutorial/New(mob/user)
src.user = user
RegisterSignal(user, COMSIG_PARENT_QDELETING, PROC_REF(destroy_self))
RegisterSignal(user.client, COMSIG_PARENT_QDELETING, PROC_REF(destroy_self))
/datum/tutorial/Destroy(force, ...)
user.client?.screen -= instruction_screen
QDEL_NULL(instruction_screen)
user = null
return ..()
/// Gets the [`/datum/tutorial_manager`] that owns this tutorial.
/datum/tutorial/proc/manager()
RETURN_TYPE(/datum/tutorial_manager)
return SStutorials.tutorial_managers[type]
/// The actual steps of the tutorial. Is given any excess arguments of suggest_tutorial.
/// Must be overridden.
/datum/tutorial/proc/perform()
SHOULD_CALL_PARENT(FALSE)
CRASH("[type] does not override perform()")
/// Returns TRUE/FALSE if this tutorial should be given.
/// If FALSE, does not mean it won't come back later.
/datum/tutorial/proc/should_perform()
SHOULD_CALL_PARENT(FALSE)
return TRUE
/// Called by the tutorial when the user has successfully completed it.
/// Will mark it as completed in the datbaase and kick off destruction of the tutorial.
/datum/tutorial/proc/complete()
SIGNAL_HANDLER
PROTECTED_PROC(TRUE)
SHOULD_NOT_OVERRIDE(TRUE)
manager().complete(user)
perform_base_completion_effects()
/// As opposed to `complete()`, this merely hides the tutorial.
/// This should be used when the user doesn't need the tutorial anymore, but didn't
/// actually properly finish it.
/datum/tutorial/proc/dismiss()
SIGNAL_HANDLER
PROTECTED_PROC(TRUE)
SHOULD_NOT_OVERRIDE(TRUE)
manager().dismiss(user)
perform_base_completion_effects()
#define INSTRUCTION_SCREEN_DELAY (1 SECONDS)
/datum/tutorial/proc/perform_base_completion_effects()
SHOULD_NOT_OVERRIDE(TRUE)
var/delay = perform_completion_effects_with_delay()
if (!isnull(instruction_screen))
animate(instruction_screen, time = INSTRUCTION_SCREEN_DELAY, alpha = 0, easing = SINE_EASING)
delay += INSTRUCTION_SCREEN_DELAY
QDEL_IN(src, delay)
/// Called when the tutorial is being hidden, but before it is deleted.
/// You should unregister signals and fade out any of your creations in here.
/// Returns how long extra to delay the deletion.
/datum/tutorial/proc/perform_completion_effects_with_delay()
SHOULD_CALL_PARENT(FALSE)
PROTECTED_PROC(TRUE)
return 0
#undef INSTRUCTION_SCREEN_DELAY
/datum/tutorial/proc/destroy_self()
SIGNAL_HANDLER
PRIVATE_PROC(TRUE)
SHOULD_NOT_OVERRIDE(TRUE)
manager().dismiss(user)
qdel(src)
/// Shows a large piece of text on the user's screen with the given message.
/// If a message already exists, will fade it out and replace it.
/datum/tutorial/proc/show_instruction(message)
PROTECTED_PROC(TRUE)
if (isnull(instruction_screen))
instruction_screen = new(null, message, user.client)
user.client?.screen += instruction_screen
else
instruction_screen.change_message(message)
/// Given a keybind and a message, will replace %KEY% in `message` with the first keybind they have.
/// As a fallback, will return the third parameter, `message_without_keybinds`, if none are set.
/datum/tutorial/proc/keybinding_message(datum/keybinding/keybinding_type, message, message_without_keybinds)
PROTECTED_PROC(TRUE)
var/list/keybinds = user.client?.prefs.key_bindings[initial(keybinding_type.name)]
return keybinds?.len > 0 ? replacetext(message, "%KEY%", "<b>[keybinds[1]]</b>") : message_without_keybinds
/// Creates a UI element with the given `icon_state`, starts it at `initial_screen_loc`, and animates it to `target_screen_loc`.
/// Waits `animate_start_time` before moving.
/datum/tutorial/proc/animate_ui_element(icon_state, initial_screen_loc, target_screen_loc, animate_start_time)
PROTECTED_PROC(TRUE)
var/atom/movable/screen/preview = new
preview.icon = ui_style2icon(user.client?.prefs.read_preference(/datum/preference/choiced/ui_style) || GLOB.available_ui_styles[1])
preview.icon_state = icon_state
preview.mouse_opacity = MOUSE_OPACITY_TRANSPARENT
preview.screen_loc = "1,1"
var/view = user.client?.view
var/list/origin_offsets = screen_loc_to_offset(initial_screen_loc, view)
// A little offset to the right
var/matrix/origin_transform = TRANSLATE_MATRIX(origin_offsets[1] - world.icon_size * 0.5, origin_offsets[2] - world.icon_size * 1.5)
var/list/target_offsets = screen_loc_to_offset(target_screen_loc, view)
// `- world.icon_Size * 0.5` to patch over a likely bug in screen_loc_to_offset with CENTER, needs more looking at
var/matrix/animate_to_transform = TRANSLATE_MATRIX(target_offsets[1] - world.icon_size * 1.5, target_offsets[2] - world.icon_size)
preview.transform = origin_transform
preview.alpha = 0
animate(preview, time = animate_start_time, alpha = 255, easing = CUBIC_EASING)
animate(1.4 SECONDS)
animate(transform = animate_to_transform, time = 2 SECONDS, easing = SINE_EASING | EASE_IN)
animate(alpha = 0, time = 2.4 SECONDS, easing = CUBIC_EASING | EASE_IN, flags = ANIMATION_PARALLEL)
user.client?.screen += preview
return preview
/// A singleton that manages when to create tutorials of a specific tutorial type.
/datum/tutorial_manager
VAR_PRIVATE/datum/tutorial/tutorial_type
/// ckeys that we know have finished the tutorial
VAR_PRIVATE/list/finished_ckeys = list()
/// ckeys that have performed the tutorial, but have not completed it.
/// Doesn't mean that they can still see the tutorial, might have meant the tutorial was dismissed
/// without being completed, such as during a log out.
VAR_PRIVATE/list/performing_ckeys = list()
/datum/tutorial_manager/New(tutorial_type)
ASSERT(ispath(tutorial_type, /datum/tutorial))
src.tutorial_type = tutorial_type
/datum/tutorial_manager/Destroy(force, ...)
if (!force)
stack_trace("Something is trying to destroy [type], which is a singleton")
return QDEL_HINT_LETMELIVE
return ..()
/// Checks if we should perform the tutorial for the given user, and performs if so.
/// Use `SStutorials.suggest_tutorial` instead of calling this directly.
/datum/tutorial_manager/proc/try_perform(mob/user, list/arguments)
var/datum/tutorial/tutorial = new tutorial_type(user)
if (!tutorial.should_perform(user))
qdel(tutorial)
return
performing_ckeys[user.ckey] = TRUE
tutorial.perform(arglist(arguments))
/// Checks if the user should be given this tutorial
/datum/tutorial_manager/proc/should_run(mob/user)
var/ckey = user.ckey
if (isnull(ckey))
return FALSE
if (ckey in finished_ckeys)
return FALSE
if (ckey in performing_ckeys)
return FALSE
if (!SSdbcore.IsConnected())
return CONFIG_GET(flag/give_tutorials_without_db)
var/player_join_date = user.client?.player_join_date
if (isnull(player_join_date))
return FALSE
// This works because ISO-8601 is cool
var/grandfather_date = initial(tutorial_type.grandfather_date)
if (!isnull(grandfather_date) && player_join_date < grandfather_date)
return FALSE
return TRUE
/// Marks the tutorial as completed.
/// Call `/datum/tutorial/proc/complete()` instead.
/datum/tutorial_manager/proc/complete(mob/user)
set waitfor = FALSE
ASSERT(!isnull(user.ckey))
finished_ckeys[user.ckey] = TRUE
performing_ckeys -= user.ckey
SSblackbox.record_feedback("tally", "tutorial_completed", 1, "[tutorial_type]")
log_game("[key_name(user)] completed the [tutorial_type] tutorial.")
if (SSdbcore.IsConnected())
INVOKE_ASYNC(src, PROC_REF(log_completion_to_database), user.ckey)
/datum/tutorial_manager/proc/log_completion_to_database(ckey)
PRIVATE_PROC(TRUE)
var/datum/db_query/insert_tutorial_query = SSdbcore.NewQuery(
"INSERT INTO [format_table_name("tutorial_completions")] (ckey, tutorial_key) VALUES (:ckey, :tutorial_key) ON DUPLICATE KEY UPDATE tutorial_key = tutorial_key",
list(
"ckey" = ckey,
"tutorial_key" = get_key(),
)
)
insert_tutorial_query.warn_execute()
qdel(insert_tutorial_query)
/// Dismisses the tutorial, not marking it as completed in the database.
/// Call `/datum/tutorial/proc/dismiss()` instead.
/datum/tutorial_manager/proc/dismiss(mob/user)
performing_ckeys -= user.ckey
/// Given a ckey, will mark them as being completed without affecting the database.
/// Call `/datum/tutorial/proc/complete()` instead.
/datum/tutorial_manager/proc/mark_as_completed(ckey)
finished_ckeys[ckey] = TRUE
performing_ckeys -= ckey
/// Gives the key that will be saved in the database.
/// Must be 64 characters or less.
/datum/tutorial_manager/proc/get_key()
SHOULD_BE_PURE(TRUE)
return copytext("[tutorial_type]", length("[/datum/tutorial]") + 2)
@@ -0,0 +1,53 @@
/atom/movable/screen/tutorial_instruction
icon = 'icons/effects/alphacolors.dmi'
icon_state = "white"
color = COLOR_NEARLY_ALL_BLACK
alpha = 0
screen_loc = "TOP-2,CENTER"
layer = TUTORIAL_INSTRUCTIONS_LAYER
mouse_opacity = MOUSE_OPACITY_TRANSPARENT
var/client/client
var/atom/movable/screen/tutorial_instruction_text/instruction_text
/atom/movable/screen/tutorial_instruction/Initialize(mapload, message, client/client)
. = ..()
transform = transform.Scale(36, 2.5)
src.client = client
animate(src, alpha = 245, time = 0.8 SECONDS, easing = SINE_EASING)
instruction_text = new(src, message, client)
vis_contents += instruction_text
/atom/movable/screen/tutorial_instruction/Destroy()
client = null
QDEL_NULL(instruction_text)
return ..()
/atom/movable/screen/tutorial_instruction/proc/change_message(message)
instruction_text.change_message(message)
/atom/movable/screen/tutorial_instruction_text
maptext_height = 480
maptext_y = -2
mouse_opacity = MOUSE_OPACITY_TRANSPARENT
layer = TUTORIAL_INSTRUCTIONS_LAYER
/atom/movable/screen/tutorial_instruction_text/Initialize(mapload, message, client/client)
. = ..()
var/view = client?.view_size.getView()
maptext_width = view ? view_to_pixels(view)[1] : 480
pixel_x = (maptext_width - world.icon_size) * -0.5
change_message(message)
/atom/movable/screen/tutorial_instruction_text/proc/change_message(message)
// We don't use MAPTEXT macro here because it doesn't handle big text
message = "<span style='font-family: \"VCR OSD Mono\"; font-size: 22px; text-align: center'>[message]</span>"
animate(src, alpha = 0, time = (maptext ? 0.5 SECONDS : 0), easing = SINE_EASING)
animate(alpha = 255, time = 0.5 SECONDS, maptext = message)
+109
View File
@@ -0,0 +1,109 @@
#define TIME_TO_START_MOVING_DROP_ICON (0.5 SECONDS)
#define STAGE_DROP_ITEM "STAGE_DROP_ITEM"
#define STAGE_PICK_SOMETHING_UP "STAGE_PICK_SOMETHING_UP"
/// Tutorial for showing how to drop items.
/// Fired when clicking on an item with another item with a filled inactive hand.
/datum/tutorial/drop
grandfather_date = "2023-01-07"
var/stage = STAGE_DROP_ITEM
var/atom/movable/screen/drop_preview
var/obj/last_held_item
/datum/tutorial/drop/Destroy(force, ...)
last_held_item = null
user.client?.screen -= drop_preview
QDEL_NULL(drop_preview)
return ..()
/datum/tutorial/drop/perform(list/params)
create_drop_preview(params[SCREEN_LOC])
addtimer(CALLBACK(src, PROC_REF(show_instructions)), TIME_TO_START_MOVING_DROP_ICON)
RegisterSignal(user, COMSIG_MOB_DROPPING_ITEM, PROC_REF(on_dropped_item))
RegisterSignal(user, COMSIG_MOB_SWAP_HANDS, PROC_REF(on_swap_hands))
RegisterSignal(user, COMSIG_LIVING_PICKED_UP_ITEM, PROC_REF(on_pick_up_item))
update_held_item()
/datum/tutorial/drop/perform_completion_effects_with_delay()
UnregisterSignal(user, list(COMSIG_MOB_DROPPING_ITEM, COMSIG_MOB_SWAP_HANDS, COMSIG_LIVING_PICKED_UP_ITEM))
if (!isnull(last_held_item))
UnregisterSignal(last_held_item, COMSIG_MOVABLE_MOVED)
return 0
/datum/tutorial/drop/proc/create_drop_preview(initial_screen_loc)
drop_preview = animate_ui_element(
"act_drop",
initial_screen_loc,
ui_drop_throw,
TIME_TO_START_MOVING_DROP_ICON,
)
/datum/tutorial/drop/proc/show_instructions()
if (QDELETED(src))
return
switch (stage)
if (STAGE_DROP_ITEM)
show_instruction(keybinding_message(
/datum/keybinding/mob/drop_item,
"Press '%KEY%' to drop your current item",
"Click '<b>DROP</b>' to drop your current item",
))
if (STAGE_PICK_SOMETHING_UP)
show_instruction("Pick something up!")
/datum/tutorial/drop/proc/on_swap_hands()
SIGNAL_HANDLER
if (isnull(user.get_active_held_item()))
if (stage != STAGE_PICK_SOMETHING_UP)
stage = STAGE_PICK_SOMETHING_UP
show_instructions()
else if (stage == STAGE_PICK_SOMETHING_UP)
stage = STAGE_DROP_ITEM
show_instructions()
update_held_item()
/datum/tutorial/drop/proc/on_dropped_item()
SIGNAL_HANDLER
stage = STAGE_PICK_SOMETHING_UP
show_instructions()
/datum/tutorial/drop/proc/on_pick_up_item()
SIGNAL_HANDLER
if (stage != STAGE_PICK_SOMETHING_UP)
dismiss()
return
complete()
// Exists so that if we, say, place the item on a table, we don't count that as completion
/datum/tutorial/drop/proc/update_held_item()
if (!isnull(last_held_item))
UnregisterSignal(last_held_item, COMSIG_MOVABLE_MOVED)
last_held_item = user.get_active_held_item()
if (isnull(last_held_item))
return
RegisterSignal(last_held_item, COMSIG_MOVABLE_MOVED, PROC_REF(on_held_item_moved))
/datum/tutorial/drop/proc/on_held_item_moved()
SIGNAL_HANDLER
if (stage == STAGE_PICK_SOMETHING_UP)
return
dismiss()
#undef STAGE_DROP_ITEM
#undef STAGE_PICK_SOMETHING_UP
#undef TIME_TO_START_MOVING_DROP_ICON
@@ -0,0 +1,86 @@
#define TIME_TO_START_MOVING_HAND_ICON (0.5 SECONDS)
#define STAGE_SHOULD_SWAP_HAND "STAGE_SHOULD_SWAP_HAND"
#define STAGE_PICK_UP_ITEM "STAGE_PICK_UP_ITEM"
/// Tutorial for showing how to switch hands.
/// Fired when clicking on an item with another item with an empty inactive hand.
/datum/tutorial/switch_hands
grandfather_date = "2023-01-07"
var/stage = STAGE_SHOULD_SWAP_HAND
var/atom/movable/screen/hand_preview
// So that they don't just drop the item
var/hand_to_watch
/datum/tutorial/switch_hands/New(mob/user)
. = ..()
hand_to_watch = (user.active_hand_index % user.held_items.len) + 1
/datum/tutorial/switch_hands/Destroy(force, ...)
user.client?.screen -= hand_preview
QDEL_NULL(hand_preview)
return ..()
/datum/tutorial/switch_hands/perform(list/params)
create_hand_preview(params[SCREEN_LOC])
addtimer(CALLBACK(src, PROC_REF(show_instructions)), TIME_TO_START_MOVING_HAND_ICON)
RegisterSignal(user, COMSIG_MOB_SWAP_HANDS, PROC_REF(on_swap_hands))
RegisterSignal(user, COMSIG_LIVING_PICKED_UP_ITEM, PROC_REF(on_pick_up_item))
/datum/tutorial/switch_hands/perform_completion_effects_with_delay()
UnregisterSignal(user, list(COMSIG_MOB_SWAP_HANDS, COMSIG_LIVING_PICKED_UP_ITEM))
return 0
/datum/tutorial/switch_hands/proc/create_hand_preview(initial_screen_loc)
hand_preview = animate_ui_element(
"hand_[hand_to_watch % 2 == 0 ? "r" : "l"]",
initial_screen_loc,
ui_hand_position(hand_to_watch),
TIME_TO_START_MOVING_HAND_ICON,
)
/datum/tutorial/switch_hands/proc/show_instructions()
if (QDELETED(src))
return
switch (stage)
if (STAGE_SHOULD_SWAP_HAND)
var/hand_name = hand_to_watch % 2 == 0 ? "right" : "left"
show_instruction(keybinding_message(
/datum/keybinding/mob/swap_hands,
"Press '%KEY%' to use your [hand_name] hand",
"Click '<b>SWAP</b>' to use your [hand_name] hand",
))
if (STAGE_PICK_UP_ITEM)
show_instruction("Pick something up!")
/datum/tutorial/switch_hands/proc/on_swap_hands()
SIGNAL_HANDLER
if (isnull(user.get_active_held_item()))
stage = STAGE_PICK_UP_ITEM
show_instructions()
else if (isnull(user.get_inactive_held_item()))
stage = STAGE_SHOULD_SWAP_HAND
show_instructions()
else
// You somehow got an item in both hands during the tutorial without switching hands.
// Good job I guess?
complete()
/datum/tutorial/switch_hands/proc/on_pick_up_item()
SIGNAL_HANDLER
if (user.active_hand_index != hand_to_watch)
return
complete()
#undef STAGE_PICK_UP_ITEM
#undef STAGE_SHOULD_SWAP_HAND
#undef TIME_TO_START_MOVING_HAND_ICON