Contextual tutorials for swapping hands and dropping items (#72292)

# Requires https://github.com/tgstation/tgstation/pull/72320

## About The Pull Request


https://user-images.githubusercontent.com/35135081/209700892-e54be6cf-d18c-4d12-acd1-e5eb46e9d82d.mp4


https://user-images.githubusercontent.com/35135081/209700911-751b8a0e-d770-49fa-a6eb-ce50aa0fa670.mp4

---

Adds a system for tutorials that:

- Are contextually given
- Are not given again after completion
- Can optionally not trigger for anyone who first played before a
certain date

Uses this system for a tutorial for switching hands/dropping items. This
tutorial is triggered when you try to click on an item with another
item, and `afterattack` return FALSE. In order for this to work as
smoothly as possible, I'm going to open a separate PR that cleans up the
`afterattack` on everything to either return TRUE/FALSE.

## Why It's Good For The Game

SS13 is an extremely confusing game, being able to do tutorials in a
non-intrusive way (like a separate tutorial mode) is nice.

The system in place is going to be perfectly usable for introducing
mechanics to both fresh players and experienced players alike (such as
for future content).

## Changelog
🆑
qol: New players will now get a contextual tutorial for how to switch
hands and drop items.
/🆑

Co-authored-by: Fikou <23585223+Fikou@users.noreply.github.com>
This commit is contained in:
Mothblocks
2023-01-08 16:29:18 -08:00
committed by GitHub
parent 8c2c03998f
commit 9740f104d0
31 changed files with 763 additions and 18 deletions

View File

@@ -2,14 +2,28 @@ Any time you make a change to the schema files, remember to increment the databa
Make sure to also update `DB_MAJOR_VERSION` and `DB_MINOR_VERSION`, which can be found in `code/__DEFINES/subsystem.dm`. Make sure to also update `DB_MAJOR_VERSION` and `DB_MINOR_VERSION`, which can be found in `code/__DEFINES/subsystem.dm`.
The latest database version is 5.21; The query to update the schema revision table is: The latest database version is 5.23; The query to update the schema revision table is:
INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 22); INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 23);
or or
INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 22); INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 23);
In any query remember to add a prefix to the table names if you use one. In any query remember to add a prefix to the table names if you use one.
-----------------------------------------------------
Version 5.23, 28 December 2022, by Mothblocks
Added `tutorial_completions` to mark what ckeys have completed contextual tutorials.
```
CREATE TABLE `tutorial_completions` (
`id` INT NOT NULL AUTO_INCREMENT,
`ckey` VARCHAR(32) NOT NULL,
`tutorial_key` VARCHAR(64) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `ckey_tutorial_unique` (`ckey`, `tutorial_key`));
```
-----------------------------------------------------
Version 5.22, 22 December 2021, by Mothblocks Version 5.22, 22 December 2021, by Mothblocks
Fixes a bug in `telemetry_connections` that limited the range of IPs. Fixes a bug in `telemetry_connections` that limited the range of IPs.

View File

@@ -693,6 +693,14 @@ CREATE TABLE `telemetry_connections` (
UNIQUE INDEX `unique_constraints` (`ckey` , `telemetry_ckey` , `address` , `computer_id`) UNIQUE INDEX `unique_constraints` (`ckey` , `telemetry_ckey` , `address` , `computer_id`)
); );
DROP TABLE IF EXISTS `tutorial_completions`;
CREATE TABLE `tutorial_completions` (
`id` INT NOT NULL AUTO_INCREMENT,
`ckey` VARCHAR(32) NOT NULL,
`tutorial_key` VARCHAR(64) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `ckey_tutorial_unique` (`ckey`, `tutorial_key`));
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;

View File

@@ -693,6 +693,14 @@ CREATE TABLE `SS13_telemetry_connections` (
UNIQUE INDEX `unique_constraints` (`ckey` , `telemetry_ckey` , `address` , `computer_id`) UNIQUE INDEX `unique_constraints` (`ckey` , `telemetry_ckey` , `address` , `computer_id`)
); );
DROP TABLE IF EXISTS `SS13_tutorial_completions`;
CREATE TABLE `SS13_tutorial_completions` (
`id` INT NOT NULL AUTO_INCREMENT,
`ckey` VARCHAR(32) NOT NULL,
`tutorial_key` VARCHAR(64) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `ckey_tutorial_unique` (`ckey`, `tutorial_key`));
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;

View File

@@ -28,6 +28,7 @@
#define COLOR_ALMOST_BLACK "#333333" #define COLOR_ALMOST_BLACK "#333333"
#define COLOR_FULL_TONER_BLACK "#101010" #define COLOR_FULL_TONER_BLACK "#101010"
#define COLOR_PRISONER_BLACK "#292929" #define COLOR_PRISONER_BLACK "#292929"
#define COLOR_NEARLY_ALL_BLACK "#111111"
#define COLOR_BLACK "#000000" #define COLOR_BLACK "#000000"
#define COLOR_HALF_TRANSPARENT_BLACK "#0000007A" #define COLOR_HALF_TRANSPARENT_BLACK "#0000007A"

View File

@@ -133,5 +133,9 @@
/// From /mob/living/befriend() : (mob/living/new_friend) /// From /mob/living/befriend() : (mob/living/new_friend)
#define COMSIG_LIVING_BEFRIENDED "living_befriended" #define COMSIG_LIVING_BEFRIENDED "living_befriended"
/// From /obj/item/proc/pickup(): (/obj/item/picked_up_item)
#define COMSIG_LIVING_PICKED_UP_ITEM "living_picked_up_item"
/// From /mob/living/unfriend() : (mob/living/old_friend) /// From /mob/living/unfriend() : (mob/living/old_friend)
#define COMSIG_LIVING_UNFRIENDED "living_unfriended" #define COMSIG_LIVING_UNFRIENDED "living_unfriended"

View File

@@ -116,9 +116,12 @@
#define MOB_DEADSAY_SIGNAL_INTERCEPT (1<<0) #define MOB_DEADSAY_SIGNAL_INTERCEPT (1<<0)
///from /mob/living/emote(): () ///from /mob/living/emote(): ()
#define COMSIG_MOB_EMOTE "mob_emote" #define COMSIG_MOB_EMOTE "mob_emote"
///from base of mob/swap_hand(): (obj/item) ///from base of mob/swap_hand(): (obj/item/currently_held_item)
#define COMSIG_MOB_SWAP_HANDS "mob_swap_hands" #define COMSIG_MOB_SWAPPING_HANDS "mob_swapping_hands"
#define COMPONENT_BLOCK_SWAP (1<<0) #define COMPONENT_BLOCK_SWAP (1<<0)
/// from base of mob/swap_hand(): ()
/// Performed after the hands are swapped.
#define COMSIG_MOB_SWAP_HANDS "mob_swap_hands"
///from base of /mob/verb/pointed: (atom/A) ///from base of /mob/verb/pointed: (atom/A)
#define COMSIG_MOB_POINTED "mob_pointed" #define COMSIG_MOB_POINTED "mob_pointed"
///Mob is trying to open the wires of a target [/atom], from /datum/wires/interactable(): (atom/target) ///Mob is trying to open the wires of a target [/atom], from /datum/wires/interactable(): (atom/target)
@@ -171,5 +174,9 @@
///from living/flash_act(), when a mob is successfully flashed. ///from living/flash_act(), when a mob is successfully flashed.
#define COMSIG_MOB_FLASHED "mob_flashed" #define COMSIG_MOB_FLASHED "mob_flashed"
/// from mob/get_status_tab_items(): (list/items) /// from mob/get_status_tab_items(): (list/items)
#define COMSIG_MOB_GET_STATUS_TAB_ITEMS "mob_get_status_tab_items" #define COMSIG_MOB_GET_STATUS_TAB_ITEMS "mob_get_status_tab_items"
/// from mob/proc/dropItemToGround()
#define COMSIG_MOB_DROPPING_ITEM "mob_dropping_item"

View File

@@ -240,6 +240,9 @@
///Layer for screentips ///Layer for screentips
#define SCREENTIP_LAYER 4 #define SCREENTIP_LAYER 4
/// Layer for tutorial instructions
#define TUTORIAL_INSTRUCTIONS_LAYER 5
#define LOBBY_BACKGROUND_LAYER 3 #define LOBBY_BACKGROUND_LAYER 3
#define LOBBY_BUTTON_LAYER 4 #define LOBBY_BUTTON_LAYER 4

View File

@@ -1,2 +1,3 @@
/// Helper macro for compile time translation /// Helper macro for creating a matrix at the given offsets.
/// Works at compile time.
#define TRANSLATE_MATRIX(offset_x, offset_y) matrix(1, 0, (offset_x), 0, 1, (offset_y)) #define TRANSLATE_MATRIX(offset_x, offset_y) matrix(1, 0, (offset_x), 0, 1, (offset_y))

View File

@@ -20,7 +20,7 @@
* *
* make sure you add an update to the schema_version stable in the db changelog * make sure you add an update to the schema_version stable in the db changelog
*/ */
#define DB_MINOR_VERSION 22 #define DB_MINOR_VERSION 23
//! ## Timing subsystem //! ## Timing subsystem

View File

@@ -18,14 +18,26 @@
y += view_size[2] y += view_size[2]
if(findtext(screen_loc, "SOUTH")) if(findtext(screen_loc, "SOUTH"))
y += world.icon_size y += world.icon_size
// Cut out everything we just parsed
screen_loc = cut_relative_direction(screen_loc)
var/list/x_and_y = splittext(screen_loc, ",") var/list/x_and_y = splittext(screen_loc, ",")
var/list/x_pack = splittext(x_and_y[1], ":") var/list/x_pack = splittext(x_and_y[1], ":")
var/list/y_pack = splittext(x_and_y[2], ":") var/list/y_pack = splittext(x_and_y[2], ":")
x += text2num(x_pack[1]) * world.icon_size
y += text2num(y_pack[1]) * world.icon_size var/x_coord = x_pack[1]
var/y_coord = y_pack[1]
if (findtext(x_coord, "CENTER"))
x += view_size[1] / 2
if (findtext(y_coord, "CENTER"))
y += view_size[2] / 2
x_coord = text2num(cut_relative_direction(x_coord))
y_coord = text2num(cut_relative_direction(y_coord))
x += x_coord * world.icon_size
y += y_coord * world.icon_size
if(length(x_pack) > 1) if(length(x_pack) > 1)
x += text2num(x_pack[2]) x += text2num(x_pack[2])

View File

@@ -59,6 +59,14 @@
if (after_attack_secondary_result == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN || after_attack_secondary_result == SECONDARY_ATTACK_CONTINUE_CHAIN) if (after_attack_secondary_result == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN || after_attack_secondary_result == SECONDARY_ATTACK_CONTINUE_CHAIN)
return TRUE return TRUE
var/afterattack_result = afterattack(target, user, TRUE, params)
if (!(afterattack_result & AFTERATTACK_PROCESSED_ITEM) && isitem(target))
if (isnull(user.get_inactive_held_item()))
SStutorials.suggest_tutorial(user, /datum/tutorial/switch_hands, params2list(params))
else
SStutorials.suggest_tutorial(user, /datum/tutorial/drop, params2list(params))
return afterattack(target, user, TRUE, params) == TRUE return afterattack(target, user, TRUE, params) == TRUE
/// Called when the item is in the active hand, and clicked; alternately, there is an 'activate held object' verb or you can hit pagedown. /// Called when the item is in the active hand, and clicked; alternately, there is an 'activate held object' verb or you can hit pagedown.

View File

@@ -414,3 +414,4 @@
/datum/config_entry/flag/disallow_circuit_sounds /datum/config_entry/flag/disallow_circuit_sounds
/datum/config_entry/flag/give_tutorials_without_db

View File

@@ -0,0 +1,111 @@
/// Namespace for housing code relating to giving contextual tutorials to users.
SUBSYSTEM_DEF(tutorials)
name = "Tutorials"
flags = SS_NO_FIRE
/// A mapping of /datum/tutorial type to their manager singleton.
/// You probably shouldn't be indexing this directly.
var/list/datum/tutorial_manager/tutorial_managers = list()
VAR_PRIVATE/list/datum/tutorial_manager/tutorial_managers_by_key = list()
/datum/controller/subsystem/tutorials/Initialize()
init_tutorial_managers()
load_initial_tutorial_completions()
RegisterSignal(SSdcs, COMSIG_GLOB_CLIENT_CONNECT, PROC_REF(on_client_connect))
return SS_INIT_SUCCESS
/// Will suggest the passed tutorial type to the user.
/// Will check that they should actually see it, e.g. hasn't completed it yet, etc.
/// Then, calls `/datum/tutorial/subtype/perform` with the extra arguments passed in.
/datum/controller/subsystem/tutorials/proc/suggest_tutorial(mob/user, datum/tutorial/tutorial_type, ...)
var/datum/tutorial_manager/tutorial_manager = tutorial_managers[tutorial_type]
if (isnull(tutorial_manager))
CRASH("[tutorial_type] is not a valid tutorial type")
if (!tutorial_manager.should_run(user))
return
INVOKE_ASYNC(tutorial_manager, TYPE_PROC_REF(/datum/tutorial_manager, try_perform), user, args.Copy(3))
/datum/controller/subsystem/tutorials/proc/init_tutorial_managers()
PRIVATE_PROC(TRUE)
for (var/datum/tutorial/tutorial_type as anything in subtypesof(/datum/tutorial))
var/datum/tutorial_manager/tutorial_manager = new /datum/tutorial_manager(tutorial_type)
tutorial_managers[tutorial_type] = tutorial_manager
tutorial_managers_by_key[tutorial_manager.get_key()] = tutorial_manager
/datum/controller/subsystem/tutorials/proc/load_initial_tutorial_completions()
PRIVATE_PROC(TRUE)
set waitfor = FALSE // There's no reason to halt init for this
var/list/ckey_options = list()
var/list/ckeys = list()
for (var/client/client as anything in GLOB.clients)
var/ckey = client?.ckey
if (!ckey)
continue // client shenanigans, never trust
var/index = ckeys.len + 1
ckey_options += ":ckey[index]"
ckeys["ckey[index]"] = ckey
if (ckey_options.len == 0)
return
var/datum/db_query/select_all_query = SSdbcore.NewQuery(
"SELECT ckey, tutorial_key FROM [format_table_name("tutorial_completions")] WHERE ckey in ([ckey_options.Join(", ")])",
ckeys,
)
if (!select_all_query.Execute())
qdel(select_all_query)
return
while (select_all_query.NextRow())
var/ckey = select_all_query.item[1]
var/tutorial_key = select_all_query.item[2]
mark_ckey_completed_tutorial(ckey, tutorial_key)
qdel(select_all_query)
/datum/controller/subsystem/tutorials/proc/on_client_connect(datum/source, client/client)
SIGNAL_HANDLER
var/ckey = client.ckey
if (!ckey)
return
INVOKE_ASYNC(src, PROC_REF(check_completed_tutorials_for_ckey), ckey)
/datum/controller/subsystem/tutorials/proc/check_completed_tutorials_for_ckey(ckey)
if (!SSdbcore.IsConnected())
return
var/datum/db_query/select_tutorials_for_ckey = SSdbcore.NewQuery(
"SELECT tutorial_key FROM [format_table_name("tutorial_completions")] WHERE ckey = :ckey",
list("ckey" = ckey)
)
if (!select_tutorials_for_ckey.Execute())
return
while (select_tutorials_for_ckey.NextRow())
var/tutorial_key = select_tutorials_for_ckey.item[1]
mark_ckey_completed_tutorial(ckey, tutorial_key)
qdel(select_tutorials_for_ckey)
/datum/controller/subsystem/tutorials/proc/mark_ckey_completed_tutorial(ckey, tutorial_key)
var/datum/tutorial_manager/tutorial_manager = tutorial_managers_by_key[tutorial_key]
if (isnull(tutorial_manager))
// Not necessarily a bug.
// Could be an outdated server or a removed tutorial.
return
tutorial_manager.mark_as_completed(ckey)

View File

@@ -173,7 +173,7 @@
return // blocked wield from item return // blocked wield from item
wielded = TRUE wielded = TRUE
ADD_TRAIT(parent, TRAIT_WIELDED, REF(src)) ADD_TRAIT(parent, TRAIT_WIELDED, REF(src))
RegisterSignal(user, COMSIG_MOB_SWAP_HANDS, PROC_REF(on_swap_hands)) RegisterSignal(user, COMSIG_MOB_SWAPPING_HANDS, PROC_REF(on_swapping_hands))
wield_callback?.Invoke(parent, user) wield_callback?.Invoke(parent, user)
// update item stats and name // update item stats and name
@@ -307,7 +307,7 @@
/** /**
* on_swap_hands Triggers on swapping hands, blocks swap if the other hand is busy * on_swap_hands Triggers on swapping hands, blocks swap if the other hand is busy
*/ */
/datum/component/two_handed/proc/on_swap_hands(mob/user, obj/item/held_item) /datum/component/two_handed/proc/on_swapping_hands(mob/user, obj/item/held_item)
SIGNAL_HANDLER SIGNAL_HANDLER
if(!held_item) if(!held_item)

View File

@@ -658,6 +658,7 @@ GLOBAL_DATUM_INIT(fire_overlay, /mutable_appearance, mutable_appearance('icons/e
/obj/item/proc/pickup(mob/user) /obj/item/proc/pickup(mob/user)
SHOULD_CALL_PARENT(TRUE) SHOULD_CALL_PARENT(TRUE)
SEND_SIGNAL(src, COMSIG_ITEM_PICKUP, user) SEND_SIGNAL(src, COMSIG_ITEM_PICKUP, user)
SEND_SIGNAL(user, COMSIG_LIVING_PICKED_UP_ITEM, src)
item_flags |= IN_INVENTORY item_flags |= IN_INVENTORY
/// called when "found" in pockets and storage items. Returns 1 if the search should end. /// called when "found" in pockets and storage items. Returns 1 if the search should end.

View File

@@ -280,9 +280,15 @@
* If the item can be dropped, it will be forceMove()'d to the ground and the turf's Entered() will be called. * If the item can be dropped, it will be forceMove()'d to the ground and the turf's Entered() will be called.
*/ */
/mob/proc/dropItemToGround(obj/item/I, force = FALSE, silent = FALSE, invdrop = TRUE) /mob/proc/dropItemToGround(obj/item/I, force = FALSE, silent = FALSE, invdrop = TRUE)
if (isnull(I))
return TRUE
SEND_SIGNAL(src, COMSIG_MOB_DROPPING_ITEM)
. = doUnEquip(I, force, drop_location(), FALSE, invdrop = invdrop, silent = silent) . = doUnEquip(I, force, drop_location(), FALSE, invdrop = invdrop, silent = silent)
if(!. || !I) //ensure the item exists and that it was dropped properly. if(!. || !I) //ensure the item exists and that it was dropped properly.
return return
if(!(I.item_flags & NO_PIXEL_RANDOM_DROP)) if(!(I.item_flags & NO_PIXEL_RANDOM_DROP))
I.pixel_x = I.base_pixel_x + rand(-6, 6) I.pixel_x = I.base_pixel_x + rand(-6, 6)
I.pixel_y = I.base_pixel_y + rand(-6, 6) I.pixel_y = I.base_pixel_y + rand(-6, 6)

View File

@@ -30,7 +30,7 @@
QDEL_NULL(dna) QDEL_NULL(dna)
GLOB.carbon_list -= src GLOB.carbon_list -= src
/mob/living/carbon/swap_hand(held_index) /mob/living/carbon/perform_hand_swap(held_index)
. = ..() . = ..()
if(!.) if(!.)
return return

View File

@@ -397,7 +397,7 @@
if(slot_num > 4) // not >3 otherwise cycling with just one item on module 3 wouldn't work if(slot_num > 4) // not >3 otherwise cycling with just one item on module 3 wouldn't work
slot_num = 1 //Wrap around. slot_num = 1 //Wrap around.
/mob/living/silicon/robot/swap_hand() /mob/living/silicon/robot/perform_hand_swap()
cycle_modules() cycle_modules()
/mob/living/silicon/robot/can_hold_items(obj/item/I) /mob/living/silicon/robot/can_hold_items(obj/item/I)

View File

@@ -616,7 +616,7 @@
else else
mode() mode()
/mob/living/simple_animal/swap_hand(hand_index) /mob/living/simple_animal/perform_hand_swap(hand_index)
. = ..() . = ..()
if(!.) if(!.)
return return

View File

@@ -862,11 +862,23 @@
return data return data
/mob/proc/swap_hand() /mob/proc/swap_hand(held_index)
SHOULD_NOT_OVERRIDE(TRUE) // Override perform_hand_swap instead
var/obj/item/held_item = get_active_held_item() var/obj/item/held_item = get_active_held_item()
if(SEND_SIGNAL(src, COMSIG_MOB_SWAP_HANDS, held_item) & COMPONENT_BLOCK_SWAP) if(SEND_SIGNAL(src, COMSIG_MOB_SWAPPING_HANDS, held_item) & COMPONENT_BLOCK_SWAP)
to_chat(src, span_warning("Your other hand is too busy holding [held_item].")) to_chat(src, span_warning("Your other hand is too busy holding [held_item]."))
return FALSE return FALSE
var/result = perform_hand_swap(held_index)
if (result)
SEND_SIGNAL(src, COMSIG_MOB_SWAP_HANDS)
return result
/// Performs the actual ritual of swapping hands, such as setting the held index variables
/mob/proc/perform_hand_swap(held_index)
PROTECTED_PROC(TRUE)
return TRUE return TRUE
/mob/proc/activate_hand(selhand) /mob/proc/activate_hand(selhand)

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)

View File

@@ -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)

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

View File

@@ -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

View File

@@ -205,6 +205,7 @@
#include "tgui_create_message.dm" #include "tgui_create_message.dm"
#include "timer_sanity.dm" #include "timer_sanity.dm"
#include "traitor.dm" #include "traitor.dm"
#include "tutorial_sanity.dm"
#include "unit_test.dm" #include "unit_test.dm"
#include "verify_config_tags.dm" #include "verify_config_tags.dm"
#include "verify_emoji_names.dm" #include "verify_emoji_names.dm"

View File

@@ -0,0 +1,19 @@
/// Verifies that every tutorial has properly set variables
/datum/unit_test/tutorial_sanity
/datum/unit_test/tutorial_sanity/Run()
var/regex/regex_valid_date = regex(@"\d{4}-\d{2}-\d{2}")
var/list/keys = list()
for (var/datum/tutorial/tutorial_type as anything in SStutorials.tutorial_managers)
var/datum/tutorial_manager/tutorial_manager = SStutorials.tutorial_managers[tutorial_type]
var/grandfather_date = initial(tutorial_type.grandfather_date)
if (!isnull(grandfather_date))
TEST_ASSERT(regex_valid_date.Find(grandfather_date), "[tutorial_type] has an invalid grandfather_date ([grandfather_date])")
var/key = tutorial_manager.get_key()
TEST_ASSERT(!(key in keys), "[key] shows up twice")
TEST_ASSERT(length(key) < 64, "[key] is more than 64 characters, it won't fit in the SQL table.")
TEST_ASSERT_EQUAL(SStutorials.tutorial_managers.len, length(subtypesof(/datum/tutorial)), "Expected tutorial_managers to have one of every tutorial")

View File

@@ -522,3 +522,8 @@ MAXFINE 2000
## Comment if you wish to enable title music playing at the lobby screen. This flag is disabled by default to facilitate better code testing on local machines. ## Comment if you wish to enable title music playing at the lobby screen. This flag is disabled by default to facilitate better code testing on local machines.
## Do keep in mind that this flag will not affect individual player's preferences: if they opt-out on your server, it will never play for them. ## Do keep in mind that this flag will not affect individual player's preferences: if they opt-out on your server, it will never play for them.
DISALLOW_TITLE_MUSIC DISALLOW_TITLE_MUSIC
## If enabled, then when the database is disabled, all players will get tutorials.
## This is primarily useful for developing tutorials. If you have a proper DB setup, you
## don't need (or want) this.
#GIVE_TUTORIALS_WITHOUT_DB

BIN
interface/VCR_OSD_Mono.ttf Normal file

Binary file not shown.

10
interface/fonts.dm Normal file
View File

@@ -0,0 +1,10 @@
/// A font datum, it exists to define a custom font to use in a span style later.
/datum/font
/// Font name, just so people know what to put in their span style.
var/name
/// The font file we link to.
var/font_family
/datum/font/vcr_osd_mono
name = "VCR OSD Mono"
font_family = 'interface/VCR_OSD_Mono.ttf'

2
interface/license.txt Normal file
View File

@@ -0,0 +1,2 @@
VCR OSD Mono created by Riciery Leal/mrmanet. Website indicates 100% free, author confirms it's free for all to use.
(https://www.dafont.com/font-comment.php?file=vcr_osd_mono)

View File

@@ -591,6 +591,7 @@
#include "code\controllers\subsystem\timer.dm" #include "code\controllers\subsystem\timer.dm"
#include "code\controllers\subsystem\title.dm" #include "code\controllers\subsystem\title.dm"
#include "code\controllers\subsystem\traitor.dm" #include "code\controllers\subsystem\traitor.dm"
#include "code\controllers\subsystem\tutorials.dm"
#include "code\controllers\subsystem\verb_manager.dm" #include "code\controllers\subsystem\verb_manager.dm"
#include "code\controllers\subsystem\vis_overlays.dm" #include "code\controllers\subsystem\vis_overlays.dm"
#include "code\controllers\subsystem\vote.dm" #include "code\controllers\subsystem\vote.dm"
@@ -4740,6 +4741,10 @@
#include "code\modules\tgui_panel\telemetry.dm" #include "code\modules\tgui_panel\telemetry.dm"
#include "code\modules\tgui_panel\tgui_panel.dm" #include "code\modules\tgui_panel\tgui_panel.dm"
#include "code\modules\tooltip\tooltip.dm" #include "code\modules\tooltip\tooltip.dm"
#include "code\modules\tutorials\_tutorial.dm"
#include "code\modules\tutorials\tutorial_instruction.dm"
#include "code\modules\tutorials\tutorials\drop.dm"
#include "code\modules\tutorials\tutorials\switch_hands.dm"
#include "code\modules\unit_tests\_unit_tests.dm" #include "code\modules\unit_tests\_unit_tests.dm"
#include "code\modules\uplink\uplink_devices.dm" #include "code\modules\uplink\uplink_devices.dm"
#include "code\modules\uplink\uplink_items.dm" #include "code\modules\uplink\uplink_items.dm"
@@ -4970,6 +4975,7 @@
#include "code\modules\wiremod\shell\shell_items.dm" #include "code\modules\wiremod\shell\shell_items.dm"
#include "code\modules\zombie\items.dm" #include "code\modules\zombie\items.dm"
#include "code\modules\zombie\organs.dm" #include "code\modules\zombie\organs.dm"
#include "interface\fonts.dm"
#include "interface\interface.dm" #include "interface\interface.dm"
#include "interface\menu.dm" #include "interface\menu.dm"
#include "interface\stylesheet.dm" #include "interface\stylesheet.dm"