diff --git a/SQL/database_changelog.txt b/SQL/database_changelog.txt index 96165390988..58efbbb5435 100644 --- a/SQL/database_changelog.txt +++ b/SQL/database_changelog.txt @@ -2,22 +2,36 @@ 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`. -The latest database version is 5.24 (5.22 for /tg/); The query to update the schema revision table is: +The latest database version is 5.25 (5.23 for /tg/); The query to update the schema revision table is: -INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 24); +INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 25); or -INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 24); +INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 25); In any query remember to add a prefix to the table names if you use one. +----------------------------------------------------- +Version 5.25, 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.24, 22 December 2021, by Mothblocks Fixes a bug in `telemetry_connections` that limited the range of IPs. ``` ALTER TABLE `telemetry_connections` MODIFY COLUMN `address` INT(10) UNSIGNED NOT NULL; ``` ------------------------------------------------------ +----------------------------------------------------- Version 5.23, 15 December 2021, by Mothblocks Adds `telemetry_connections` table for tracking tgui telemetry. diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql index 52f999c4101..ed6ebb3c992 100644 --- a/SQL/tgstation_schema.sql +++ b/SQL/tgstation_schema.sql @@ -716,6 +716,14 @@ CREATE TABLE `telemetry_connections` ( 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 */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; diff --git a/SQL/tgstation_schema_prefixed.sql b/SQL/tgstation_schema_prefixed.sql index 3a9773b9dc5..e69cd9ee046 100644 --- a/SQL/tgstation_schema_prefixed.sql +++ b/SQL/tgstation_schema_prefixed.sql @@ -700,6 +700,14 @@ CREATE TABLE `SS13_telemetry_connections` ( 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 */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; diff --git a/code/__DEFINES/colors.dm b/code/__DEFINES/colors.dm index 1a025c502ac..9e1fe18270a 100644 --- a/code/__DEFINES/colors.dm +++ b/code/__DEFINES/colors.dm @@ -28,6 +28,7 @@ #define COLOR_ALMOST_BLACK "#333333" #define COLOR_FULL_TONER_BLACK "#101010" #define COLOR_PRISONER_BLACK "#292929" +#define COLOR_NEARLY_ALL_BLACK "#111111" #define COLOR_BLACK "#000000" #define COLOR_HALF_TRANSPARENT_BLACK "#0000007A" diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm index b878fb21823..5e5864665be 100644 --- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm +++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm @@ -133,5 +133,9 @@ /// From /mob/living/befriend() : (mob/living/new_friend) #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) #define COMSIG_LIVING_UNFRIENDED "living_unfriended" diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm index 1cac348d152..005d6594257 100644 --- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm +++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm @@ -116,9 +116,12 @@ #define MOB_DEADSAY_SIGNAL_INTERCEPT (1<<0) ///from /mob/living/emote(): () #define COMSIG_MOB_EMOTE "mob_emote" -///from base of mob/swap_hand(): (obj/item) -#define COMSIG_MOB_SWAP_HANDS "mob_swap_hands" +///from base of mob/swap_hand(): (obj/item/currently_held_item) +#define COMSIG_MOB_SWAPPING_HANDS "mob_swapping_hands" #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) #define COMSIG_MOB_POINTED "mob_pointed" ///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. #define COMSIG_MOB_FLASHED "mob_flashed" + /// from mob/get_status_tab_items(): (list/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" diff --git a/code/__DEFINES/layers.dm b/code/__DEFINES/layers.dm index a06085bbf04..2ecdda04738 100644 --- a/code/__DEFINES/layers.dm +++ b/code/__DEFINES/layers.dm @@ -240,6 +240,9 @@ ///Layer for screentips #define SCREENTIP_LAYER 4 +/// Layer for tutorial instructions +#define TUTORIAL_INSTRUCTIONS_LAYER 5 + #define LOBBY_BACKGROUND_LAYER 3 #define LOBBY_BUTTON_LAYER 4 diff --git a/code/__DEFINES/matrices.dm b/code/__DEFINES/matrices.dm index b7240751b63..1bb86f9ff47 100644 --- a/code/__DEFINES/matrices.dm +++ b/code/__DEFINES/matrices.dm @@ -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)) diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 3e6e3d385c8..68d7bfc6c4a 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -20,7 +20,7 @@ * * make sure you add an update to the schema_version stable in the db changelog */ -#define DB_MINOR_VERSION 24 +#define DB_MINOR_VERSION 25 //! ## Timing subsystem diff --git a/code/__HELPERS/screen_objs.dm b/code/__HELPERS/screen_objs.dm index aa86b6d5795..7debecf816f 100644 --- a/code/__HELPERS/screen_objs.dm +++ b/code/__HELPERS/screen_objs.dm @@ -18,14 +18,26 @@ y += view_size[2] if(findtext(screen_loc, "SOUTH")) 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_pack = splittext(x_and_y[1], ":") 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) x += text2num(x_pack[2]) diff --git a/code/_onclick/item_attack.dm b/code/_onclick/item_attack.dm index 0399d7146d5..18984c7b27f 100644 --- a/code/_onclick/item_attack.dm +++ b/code/_onclick/item_attack.dm @@ -59,6 +59,14 @@ if (after_attack_secondary_result == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN || after_attack_secondary_result == SECONDARY_ATTACK_CONTINUE_CHAIN) 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 /// Called when the item is in the active hand, and clicked; alternately, there is an 'activate held object' verb or you can hit pagedown. diff --git a/code/controllers/configuration/entries/game_options.dm b/code/controllers/configuration/entries/game_options.dm index e822a4d88e3..032ff23afee 100644 --- a/code/controllers/configuration/entries/game_options.dm +++ b/code/controllers/configuration/entries/game_options.dm @@ -414,3 +414,4 @@ /datum/config_entry/flag/disallow_circuit_sounds +/datum/config_entry/flag/give_tutorials_without_db diff --git a/code/controllers/subsystem/tutorials.dm b/code/controllers/subsystem/tutorials.dm new file mode 100644 index 00000000000..3df3df116cb --- /dev/null +++ b/code/controllers/subsystem/tutorials.dm @@ -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) diff --git a/code/datums/components/twohanded.dm b/code/datums/components/twohanded.dm index 2c0de250a2a..3c438b8c085 100644 --- a/code/datums/components/twohanded.dm +++ b/code/datums/components/twohanded.dm @@ -173,7 +173,7 @@ return // blocked wield from item wielded = TRUE 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) // update item stats and name @@ -307,7 +307,7 @@ /** * 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 if(!held_item) diff --git a/code/game/objects/items.dm b/code/game/objects/items.dm index b6747a6dcde..61368081a7b 100644 --- a/code/game/objects/items.dm +++ b/code/game/objects/items.dm @@ -678,6 +678,7 @@ GLOBAL_DATUM_INIT(fire_overlay, /mutable_appearance, mutable_appearance('icons/e /obj/item/proc/pickup(mob/user) SHOULD_CALL_PARENT(TRUE) SEND_SIGNAL(src, COMSIG_ITEM_PICKUP, user) + SEND_SIGNAL(user, COMSIG_LIVING_PICKED_UP_ITEM, src) item_flags |= IN_INVENTORY /// called when "found" in pockets and storage items. Returns 1 if the search should end. diff --git a/code/modules/mob/inventory.dm b/code/modules/mob/inventory.dm index c493ed5f35e..1549ae2acb0 100644 --- a/code/modules/mob/inventory.dm +++ b/code/modules/mob/inventory.dm @@ -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. */ /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) + if(!. || !I) //ensure the item exists and that it was dropped properly. return + if(!(I.item_flags & NO_PIXEL_RANDOM_DROP)) I.pixel_x = I.base_pixel_x + rand(-6, 6) I.pixel_y = I.base_pixel_y + rand(-6, 6) diff --git a/code/modules/mob/living/carbon/carbon.dm b/code/modules/mob/living/carbon/carbon.dm index 6b23ef61ccd..e022be2d07f 100644 --- a/code/modules/mob/living/carbon/carbon.dm +++ b/code/modules/mob/living/carbon/carbon.dm @@ -30,7 +30,7 @@ QDEL_NULL(dna) GLOB.carbon_list -= src -/mob/living/carbon/swap_hand(held_index) +/mob/living/carbon/perform_hand_swap(held_index) . = ..() if(!.) return diff --git a/code/modules/mob/living/silicon/robot/inventory.dm b/code/modules/mob/living/silicon/robot/inventory.dm index f972cb0d885..e86ca257f12 100644 --- a/code/modules/mob/living/silicon/robot/inventory.dm +++ b/code/modules/mob/living/silicon/robot/inventory.dm @@ -397,7 +397,7 @@ if(slot_num > 4) // not >3 otherwise cycling with just one item on module 3 wouldn't work slot_num = 1 //Wrap around. -/mob/living/silicon/robot/swap_hand() +/mob/living/silicon/robot/perform_hand_swap() cycle_modules() /mob/living/silicon/robot/can_hold_items(obj/item/I) diff --git a/code/modules/mob/living/simple_animal/simple_animal.dm b/code/modules/mob/living/simple_animal/simple_animal.dm index 3f206619222..eb1cf2c78a6 100644 --- a/code/modules/mob/living/simple_animal/simple_animal.dm +++ b/code/modules/mob/living/simple_animal/simple_animal.dm @@ -616,7 +616,7 @@ else mode() -/mob/living/simple_animal/swap_hand(hand_index) +/mob/living/simple_animal/perform_hand_swap(hand_index) . = ..() if(!.) return diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm index 1c39a8f1f14..24f2caa7b63 100644 --- a/code/modules/mob/mob.dm +++ b/code/modules/mob/mob.dm @@ -885,11 +885,23 @@ 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() - 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].")) 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 /mob/proc/activate_hand(selhand) diff --git a/code/modules/tutorials/_tutorial.dm b/code/modules/tutorials/_tutorial.dm new file mode 100644 index 00000000000..69f63738955 --- /dev/null +++ b/code/modules/tutorials/_tutorial.dm @@ -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%", "[keybinds[1]]") : 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) diff --git a/code/modules/tutorials/tutorial_instruction.dm b/code/modules/tutorials/tutorial_instruction.dm new file mode 100644 index 00000000000..488b3558eef --- /dev/null +++ b/code/modules/tutorials/tutorial_instruction.dm @@ -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 = "[message]" + + animate(src, alpha = 0, time = (maptext ? 0.5 SECONDS : 0), easing = SINE_EASING) + animate(alpha = 255, time = 0.5 SECONDS, maptext = message) diff --git a/code/modules/tutorials/tutorials/drop.dm b/code/modules/tutorials/tutorials/drop.dm new file mode 100644 index 00000000000..de692edab43 --- /dev/null +++ b/code/modules/tutorials/tutorials/drop.dm @@ -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 'DROP' 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 diff --git a/code/modules/tutorials/tutorials/switch_hands.dm b/code/modules/tutorials/tutorials/switch_hands.dm new file mode 100644 index 00000000000..bf27a9e9d83 --- /dev/null +++ b/code/modules/tutorials/tutorials/switch_hands.dm @@ -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 'SWAP' 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 diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index c9e9b8e7459..5760a886e4a 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -208,6 +208,7 @@ #include "tgui_create_message.dm" #include "timer_sanity.dm" #include "traitor.dm" +#include "tutorial_sanity.dm" #include "unit_test.dm" #include "verify_config_tags.dm" #include "verify_emoji_names.dm" diff --git a/code/modules/unit_tests/tutorial_sanity.dm b/code/modules/unit_tests/tutorial_sanity.dm new file mode 100644 index 00000000000..3c4ae37c9c0 --- /dev/null +++ b/code/modules/unit_tests/tutorial_sanity.dm @@ -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") diff --git a/config/game_options.txt b/config/game_options.txt index fa59a2100b0..3cf2af6c919 100644 --- a/config/game_options.txt +++ b/config/game_options.txt @@ -556,3 +556,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. ## 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 + +## 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 diff --git a/interface/VCR_OSD_Mono.ttf b/interface/VCR_OSD_Mono.ttf new file mode 100644 index 00000000000..dcca687a434 Binary files /dev/null and b/interface/VCR_OSD_Mono.ttf differ diff --git a/interface/fonts.dm b/interface/fonts.dm new file mode 100644 index 00000000000..0bfc9c728c7 --- /dev/null +++ b/interface/fonts.dm @@ -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' diff --git a/interface/license.txt b/interface/license.txt new file mode 100644 index 00000000000..5f74403c50a --- /dev/null +++ b/interface/license.txt @@ -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) diff --git a/tgstation.dme b/tgstation.dme index 4f9415f70c9..660223dc6a5 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -664,6 +664,7 @@ #include "code\controllers\subsystem\timer.dm" #include "code\controllers\subsystem\title.dm" #include "code\controllers\subsystem\traitor.dm" +#include "code\controllers\subsystem\tutorials.dm" #include "code\controllers\subsystem\verb_manager.dm" #include "code\controllers\subsystem\vis_overlays.dm" #include "code\controllers\subsystem\vote.dm" @@ -4792,6 +4793,10 @@ #include "code\modules\tgui_panel\telemetry.dm" #include "code\modules\tgui_panel\tgui_panel.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\uplink\uplink_devices.dm" #include "code\modules\uplink\uplink_items.dm" @@ -5022,6 +5027,7 @@ #include "code\modules\wiremod\shell\shell_items.dm" #include "code\modules\zombie\items.dm" #include "code\modules\zombie\organs.dm" +#include "interface\fonts.dm" #include "interface\interface.dm" #include "interface\menu.dm" #include "interface\stylesheet.dm"