bondage-college-mirr/BondageClub/Scripts/Dialog.js
2025-03-30 21:23:34 +02:00

5210 lines
181 KiB
JavaScript

"use strict";
/** @deprecated - Superseded by `span.dialog-status`. */
var DialogText = /** @type {never} */("");
/** @deprecated - Superseded by `span.dialog-status[data-default]`.*/
var DialogTextDefault = /** @type {never} */("");
/** @deprecated - Superseded by `span.dialog-status[data-timeout-id]`.*/
var DialogTextDefaultTimer = /** @type {never} */(-1);
/** The duration temporary status message show up for, in ms
* @type {number}
*/
var DialogTextDefaultDuration = 5000;
/**
* The default color to use when applying items.
* @type {null | string}
*/
var DialogColorSelect = null;
/**
* The list of available items for the selected group.
* @type {DialogInventoryItem[]}
*/
var DialogInventory = [];
/**
* The current page offset of the item list. Also used for activities.
* @type {number}
*/
var DialogInventoryOffset = 0;
/**
* The grid configuration for most item views (items, permissions, activities)
* @type {CommonGenerateGridParameters}
*/
const DialogInventoryGrid = (() => {
const gap = 20;
const padding = 25;
const topButtonSize = 90;
const itemWidth = 200;
const itemHeight = 244;
return {
x: 2000 - padding - (9 * topButtonSize + 8 * gap),
y: 185,
width: 4 * itemWidth + 3 * gap,
height: 3 * itemHeight + 2 * gap,
itemWidth,
itemHeight,
};
})();
/**
* The item currently selected in the Dialog and showing its extended screen.
*
* Note that in case this is a lock, the item being locked is available in {@link DialogFocusSourceItem}.
* @type {Item|null}
*/
var DialogFocusItem = null;
/** @type {Item|null} */
var DialogTightenLoosenItem = null;
/**
* The actual item being locked while the lock asset has its extended screen drawn.
* @type {Item|null}
*/
var DialogFocusSourceItem = null;
/** @type {null | ReturnType<typeof setTimeout>} */
var DialogFocusItemColorizationRedrawTimer = null;
/**
* The list of currently visible menu item buttons.
* @type {DialogMenuButton[]}
*/
var DialogMenuButton = [];
/**
* The dialog's current mode, what is currently shown.
* @type {null | DialogMenuMode}
*/
var DialogMenuMode = null;
/** @deprecated Use {@link DialogMenuMode}. */
var DialogColor = /** @type {never} */(null);
/** @deprecated Use {@link DialogMenuMode}. */
var DialogItemPermissionMode = /** @type {never} */(null);
/** @deprecated Use {@link DialogMenuMode}.*/
var DialogItemToLock = /** @type {never} */(null);
/** @deprecated Use {@link DialogMenuMode}. */
var DialogActivityMode = /** @type {never} */(false);
/** @deprecated Use {@link DialogMenuMode}. */
var DialogLockMenu = /** @type {never} */(false);
/** @deprecated Use {@link DialogMenuMode}. */
var DialogCraftingMenu = /** @type {never} */(false);
/**
* The group that was selected before we entered the expression coloring screen
* @type {{mode: DialogMenuMode, group: AssetItemGroup}}
*/
var DialogExpressionPreviousMode = null;
/** @type {ExpressionItem[]} */
var DialogFacialExpressions = [];
/** The maximum number of expressions per page for a given asset group. */
const DialogFacialExpressionsPerPage = 18;
/**
* The currently selected expression page number for a given asset group.
* Contains up to {@link DialogFacialExpressionsPerPage} expressions.
*/
let DialogFacialExpressionsSelectedPage = 0;
var DialogFacialExpressionsSelected = -1;
var DialogFacialExpressionsSelectedBlindnessLevel = 2;
/** @type {Character[]} */
var DialogSavedExpressionPreviews = [];
/** @type {Pose[][]} */
var DialogActivePoses = [];
var DialogExtendedMessage = "";
/**
* The list of available activities for the selected group.
* @type {ItemActivity[]}
*/
var DialogActivity = [];
/** @satisfies {Record<string, DialogSortOrder>} */
var DialogSortOrder = /** @type {const} */({
Enabled: 1,
Equipped: 2,
BothFavoriteUsable: 3,
TargetFavoriteUsable: 4,
PlayerFavoriteUsable: 5,
Usable: 6,
TargetFavoriteUnusable: 7,
PlayerFavoriteUnusable: 8,
Unusable: 9,
Blocked: 10
});
/** @type {null | DialogSelfMenuOptionType} */
var DialogSelfMenuSelected = null;
var DialogLeaveDueToItem = false; // This allows dynamic items to call DialogLeave() without crashing the game
var DialogLentLockpicks = false;
/** @type {ScreenSpecifier | null} */
var DialogGamingReturnScreen = null;
var DialogButtonDisabledTester = /Disabled(For\w+)?$/u;
/**
* The attempted action that's leading the player to struggle.
* @type {DialogStruggleActionType?}
*/
let DialogStruggleAction = null;
/**
* The item we're struggling out of, or swapping from.
* @type {Item}
*/
let DialogStrugglePrevItem = null;
/**
* The item we're swapping to.
* @type {Item}
*/
let DialogStruggleNextItem = null;
/** Whether we went through the struggle selection screen or went straight through. */
let DialogStruggleSelectMinigame = false;
/** @type {Map<string, string>} */
var PlayerDialog = new Map();
/** @type {FavoriteState[]} */
var DialogFavoriteStateDetails = [
{
TargetFavorite: true,
PlayerFavorite: true,
Icon: "FavoriteBoth",
UsableOrder: DialogSortOrder.BothFavoriteUsable,
UnusableOrder: DialogSortOrder.TargetFavoriteUnusable
},
{
TargetFavorite: true,
PlayerFavorite: false,
Icon: "Favorite",
UsableOrder: DialogSortOrder.TargetFavoriteUsable,
UnusableOrder: DialogSortOrder.TargetFavoriteUnusable
},
{
TargetFavorite: false,
PlayerFavorite: true,
Icon: "FavoritePlayer",
UsableOrder: DialogSortOrder.PlayerFavoriteUsable,
UnusableOrder: DialogSortOrder.PlayerFavoriteUnusable
},
{
TargetFavorite: false,
PlayerFavorite: false,
Icon: null,
UsableOrder: DialogSortOrder.Usable,
UnusableOrder: DialogSortOrder.Unusable
},
];
/**
* The list of menu types available when clicking on yourself
* @type {readonly DialogSelfMenuOptionType[]}
*/
var DialogSelfMenuOptions = [
{
Name: "Expression",
IsAvailable: () => true,
Draw: () => DialogDrawExpressionMenu(),
Click: () => DialogClickExpressionMenu(),
},
{
Name: "Pose",
IsAvailable: () => (CurrentScreen == "ChatRoom" || CurrentScreen == "Photographic"),
Load: () => DialogLoadPoseMenu(),
Draw: () => DialogDrawPoseMenu(),
Click: () => DialogClickPoseMenu(),
},
{
Name: "SavedExpressions",
IsAvailable: () => true,
Draw: () => DialogDrawSavedExpressionsMenu(),
Click: () => DialogClickSavedExpressionsMenu(),
},
{
Name: "OwnerRules",
IsAvailable: () => false,
Draw: () => DialogDrawOwnerRulesMenu(),
Click: () => { },
},
];
/**
* Namespace with {@link DialogLeaveFocusItem} helpers for setting up new screens.
* @namespace
*/
var DialogLeaveFocusItemHandlers = {
/**
* Screen setup callbacks for after exiting the tighten/loosen menu; screen names are used as keys.
* @type {Record<string, (item: Item) => void>}
*/
DialogTightenLoosenItem: {
Crafting: (item) => {
// Subtract deterministic modifiers so that only the difficulty factor remains
CraftingSelectedItem.DifficultyFactor = (
item.Difficulty
- SkillGetLevel(Player, "Bondage")
- item.Asset.Difficulty
- (item.Craft?.Property === "Secure" ? 4 : 0)
);
item.Difficulty = item.Asset.Difficulty;
CraftingModeSet("Name");
},
},
/**
* Screen setup callbacks for after exiting the extended item menu; screen names are used as keys.
* @type {Record<string, (item: Item) => void>}
*/
DialogFocusItem: {
Appearance: () => {
// Manually restore the focus group (if it was set) as `DialogLeave` deinitializes it
// TODO: Review the logic here as it seems that either the appearance menu is abusing DialogLeave,
// or DialogLeave is too hyper focused on dialog and doing things that it really shouldn't.
// The fact that there's a `DialogFocusItem()` > `DialogLeave()` > `DialogFocusItem()` stack trace going on here is also highly suspicious,
// suggesting that it might be moreso the former
const focusGroup = Player.FocusGroup;
DialogLeave();
Player.FocusGroup = focusGroup;
},
Crafting: (item) => {
CraftingUpdateFromItem(item);
CraftingModeSet("Name");
},
Shop2: () => {
Shop2Vars.Mode = "Preview";
Shop2Load();
},
},
};
/**
* Returns character based on argument
* @param {Character | string} C - The characer to get; can be `"Player"` to get player or empty to get current
* @returns {Character} - The actual character
*/
function DialogGetCharacter(C) {
if (typeof C === "string")
return (C.toUpperCase().trim() == "PLAYER") ? Player : CurrentCharacter;
return C;
}
/**
* Compares the player's reputation with a given value
* @param {ReputationType} RepType - The name of the reputation to check
* @param {string} Value - The value to compare
* @returns {boolean} - Returns TRUE if a specific reputation type is less or equal than a given value
*/
function DialogReputationLess(RepType, Value) { return (ReputationGet(RepType) <= parseInt(Value)); }
/**
* Compares the player's reputation with a given value
* @param {ReputationType} RepType - The name of the reputation to check
* @param {string} Value - The value to compare
* @returns {boolean} - Returns TRUE if a specific reputation type is greater or equal than a given value
*/
function DialogReputationGreater(RepType, Value) { return (ReputationGet(RepType) >= parseInt(Value)); }
/**
* Compares the player's money with a given amount
* @param {string} Amount - The amount of money that must be met
* @returns {boolean} - Returns TRUE if the player has enough money
*/
function DialogMoneyGreater(Amount) { return (parseInt(Player.Money) >= parseInt(Amount)); }
/**
* Changes a given player's account by a given amount
* @param {string} Amount - The amount that should be charged or added to the player's account
* @returns {void} - Nothing
*/
function DialogChangeMoney(Amount) { CharacterChangeMoney(Player, parseInt(Amount)); }
/**
* Alters the current player's reputation by a given amount
* @param {ReputationType} RepType - The name of the reputation to change
* @param {number|string} Value - The value, the player's reputation should be altered by
* @returns {void} - Nothing
*/
function DialogSetReputation(RepType, Value) { ReputationChange(RepType, (parseInt(ReputationGet(RepType)) * -1) + parseInt(Value)); } // Sets a fixed number for the player specific reputation
/**
* Change the player's reputation progressively through dialog options (a reputation is easier to break than to build)
* @param {ReputationType} RepType - The name of the reputation to change
* @param {number|string} Value - The value, the player's reputation should be altered by
* @returns {void} - Nothing
*/
function DialogChangeReputation(RepType, Value) { ReputationProgress(RepType, Value); }
/**
* Equips a specific item on the player from dialog
* @param {string} AssetName - The name of the asset that should be equipped
* @param {AssetGroupName} AssetGroup - The name of the corresponding asset group
* @returns {void} - Nothing
*/
function DialogWearItem(AssetName, AssetGroup) { InventoryWear(Player, AssetName, AssetGroup); }
/**
* Equips a random item from a given group to the player from dialog
* @param {AssetGroupName} AssetGroup - The name of the asset group to pick from
* @returns {void} - Nothing
*/
function DialogWearRandomItem(AssetGroup) { InventoryWearRandom(Player, AssetGroup); }
/**
* Removes an item of a specific item group from the player
* @param {AssetGroupName} AssetGroup - The item to be removed belongs to this asset group
* @returns {void} - Nothing
*/
function DialogRemoveItem(AssetGroup) { InventoryRemove(Player, AssetGroup); }
/**
* Releases a character from restraints
* @param {string | Character} C - The character to be released.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {void} - Nothing
*/
function DialogRelease(C) { CharacterRelease(DialogGetCharacter(C)); }
/**
* Strips a character naked and removes the restraints
* @param {string | Character} C - The character to be stripped and released.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {void} - Nothing
*/
function DialogNaked(C) { CharacterNaked(DialogGetCharacter(C)); }
/**
* Fully restrain a character with random items
* @param {string | Character} C - The character to be restrained.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {void} - Nothing
*/
function DialogFullRandomRestrain(C) { CharacterFullRandomRestrain(DialogGetCharacter(C)); }
/**
* Checks, if a specific log has been registered with the player
* @template {LogGroupType} T
* @param {LogNameType[T]} LogType - The name of the log to search for
* @param {T} LogGroup - The name of the log group
* @returns {boolean} - Returns true, if a specific log is registered
*/
function DialogLogQuery(LogType, LogGroup) { return LogQuery(LogType, LogGroup); }
/**
* Sets the AllowItem flag on the current character
* @param {string} Allow - The flag to set. Either "TRUE" or "FALSE"
* @returns {boolean} - The boolean version of the flag
*/
function DialogAllowItem(Allow) { return CurrentCharacter.AllowItem = (Allow.toUpperCase().trim() == "TRUE"); }
/**
* Returns the value of the AllowItem flag of a given character
* @param {string | Character} C - The character whose flag should be returned.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {boolean} - The value of the given character's AllowItem flag
*/
function DialogDoAllowItem(C) { return !DialogDontAllowItemPermission(C); }
/**
* Returns TRUE if the AllowItem flag doesn't allow putting an item on the current character
* @returns {boolean} - The reversed value of the given character's AllowItem flag
*/
function DialogDontAllowItemPermission(C) { return !DialogGetCharacter(C ? C : CurrentCharacter).AllowItem; }
/**
* Returns TRUE if no item can be used by the player on the current character because of the map distance
* @returns {boolean} - TRUE if distance is too far (more than 1 tile)
*/
function DialogDontAllowItemDistance() {
if ((CurrentCharacter == null) || CurrentCharacter.IsPlayer()) return false;
if ((CurrentScreen !== "ChatRoom") || !ChatRoomMapViewIsActive()) return false;
if (ChatRoomMapViewHasSuperPowers() || ChatRoomMapViewCharacterOnWhisperRange(CurrentCharacter)) return false;
return true;
}
/**
* Determines if the given character is kneeling
* @param {string | Character} C - The character to check
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {boolean} - Returns true, if the given character is kneeling
*/
function DialogIsKneeling(C) { return DialogGetCharacter(C).IsKneeling(); }
/**
* Determines if the player is owned by the current character
* @returns {boolean} - Returns true, if the player is owned by the current character, false otherwise
*/
function DialogIsOwner() { return CurrentCharacter.IsOwner(); }
/**
* Determines, if the current character is the player's lover
* @returns {boolean} - Returns true, if the current character is one of the player's lovers
*/
function DialogIsLover() { return CurrentCharacter.IsLoverOfCharacter(Player); }
/**
* Determines if the current character is owned by the player
* @returns {boolean} - Returns true, if the current character is owned by the player, false otherwise
*/
function DialogIsProperty() { return CurrentCharacter.IsOwnedByPlayer(); }
/**
* Checks, if a given character is currently restrained
* @param {string | Character} C - The character to check.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {boolean} - Returns true, if the given character is wearing restraints, false otherwise
*/
function DialogIsRestrained(C) { return DialogGetCharacter(C).IsRestrained(); }
/**
* Checks, if a given character is currently blinded
* @param {string | Character} C - The character to check.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {boolean} - Returns true, if the given character is blinded, false otherwise
*/
function DialogIsBlind(C) { return DialogGetCharacter(C).IsBlind(); }
/**
* Checks, if a given character is currently wearing a vibrating item
* @param {string | Character} C - The character to check.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {boolean} - Returns true, if the given character is wearing a vibrating item, false otherwise
*/
function DialogIsEgged(C) { return DialogGetCharacter(C).IsEgged(); }
/**
* Checks, if a given character is able to change her clothes
* @param {string | Character} C - The character to check.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @returns {boolean} - Returns true, if the given character is able to change clothes, false otherwise
*/
function DialogCanInteract(C) { return DialogGetCharacter(C).CanInteract(); }
/**
* Sets a new pose for the given character
* @param {string} C - The character whose pose should be altered.
* Either the player (value: Player) or the current character (value: CurrentCharacter)
* @param {null | AssetPoseName} [NewPose=null] - The new pose, the character should take.
* Can be omitted to bring the character back to the standing position.
* @returns {void} - Nothing
*/
function DialogSetPose(C, NewPose) { PoseSetActive((C.toUpperCase().trim() == "PLAYER") ? Player : CurrentCharacter, NewPose || null, true); }
/**
* Checks, whether a given skill of the player is greater or equal a given value
* @param {SkillType} SkillType - Name of the skill to check
* @param {string} Value - The value, the given skill must be compared to
* @returns {boolean} - Returns true if a specific skill is greater or equal than a given value
*/
function DialogSkillGreater(SkillType, Value) { return (parseInt(SkillGetLevel(Player, SkillType)) >= parseInt(Value)); }
/**
* Checks, if a given item is available in the player's inventory
* @param {string} InventoryName
* @param {AssetGroupName} InventoryGroup
* @returns {boolean} - Returns true, if the item is available, false otherwise
*/
function DialogInventoryAvailable(InventoryName, InventoryGroup) { return InventoryAvailable(Player, InventoryName, InventoryGroup); }
/**
* Checks, if the player is the administrator of the current chat room
* @returns {boolean} - Returns true, if the player belogs to the group of administrators for the current char room false otherwise
*/
function DialogChatRoomPlayerIsAdmin() { return (ChatRoomPlayerIsAdmin() && (CurrentScreen == "ChatRoom")); }
/**
* Checks, if a safe word can be used.
* @returns {boolean} - Returns true, if the player is currently within a chat room
*/
function DialogChatRoomCanSafeword() { return (CurrentScreen == "ChatRoom" && !AsylumGGTSIsEnabled() && Player.GameplaySettings.EnableSafeword); }
/**
* Checks if the player is currently owned.
* @returns {boolean} - Returns true, if the player is currently owned by an online player (not in trial)
*/
function DialogCanViewRules() { return Player.IsFullyOwned(); }
/**
* Checks if the has enough GGTS minutes to spend on different activities, for GGTS level 6 and up
* @param {string} Minute - The number of minutes to compare
* @returns {boolean} - TRUE if the player has enough minutes
*/
function DialogGGTSMinuteGreater(Minute) { return AsylumGGTSHasMinutes(parseInt(Minute)); }
/**
* Checks if the player can spend GGTS minutes on herself, for GGTS level 6 and up
* @returns {boolean} - TRUE if the player has enough minutes
*/
function DialogGGTSCanSpendMinutes() { return (AsylumGGTSIsEnabled() && (AsylumGGTSGetLevel(Player) >= 6)); }
/**
* The player can ask GGTS for specific actions at level 6, requiring minutes as currency
* @param {string} Action - The action to trigger
* @param {string} Minute - The number of minutes to spend
* @returns {void}
*/
function DialogGGTSAction(Action, Minute) {
AsylumGGTSDialogAction(Action, parseInt(Minute));
}
/**
* Checks if the player can beg GGTS to unlock the room
* @returns {boolean} - TRUE if GGTS can unlock
*/
function DialogGGTSCanUnlock() {
return (ChatRoomPlayerIsAdmin() && (CurrentScreen == "ChatRoom") && AsylumGGTSHasMinutes(30) && ChatRoomIsLocked());
}
/**
* Checks if the player can get the GGTS helmet, only available from GGTS
* @returns {boolean} - TRUE if GGTS can unlock
*/
function DialogGGTSCanGetHelmet() {
return (AsylumGGTSHasMinutes(200) && (!InventoryAvailable(Player, "FuturisticHelmet", "ItemHood") || !InventoryAvailable(Player, "GGTSHelmet", "ItemHood")));
}
/**
* Nurses can do special GGTS interactions with other players
* @returns {boolean} - TRUE if the player is a nurse in a GGTS room
*/
function DialogCanStartGGTSInteractions() {
return (AsylumGGTSIsEnabled() && (CurrentCharacter != null) && (AsylumGGTSGetLevel(CurrentCharacter) >= 1) && !DialogCanWatchKinkyDungeon() && (ReputationGet("Asylum") > 0));
}
/**
* Nurses can ask GGTS for specific interactions with other players
* @param {string} Interaction - The interaction to trigger
* @returns {void}
*/
function DialogGGTSInteraction(Interaction) {
AsylumGGTSDialogInteraction(Interaction);
}
/**
* Checks the prerequisite for a given dialog
* @param {DialogLine} dialog - The dialog to check
* @returns {boolean} - Returns true, if the prerequisite is met, false otherwise
*/
function DialogPrerequisite(dialog) {
if (dialog.Prerequisite == null)
return true;
else if (dialog.Prerequisite.indexOf("Player.") == 0)
return Player[dialog.Prerequisite.substring(7, 250).replace("()", "").trim()]();
else if (dialog.Prerequisite.indexOf("!Player.") == 0)
return !Player[dialog.Prerequisite.substring(8, 250).replace("()", "").trim()]();
else if (dialog.Prerequisite.indexOf("CurrentCharacter.") == 0)
return CurrentCharacter[dialog.Prerequisite.substring(17, 250).replace("()", "").trim()]();
else if (dialog.Prerequisite.indexOf("!CurrentCharacter.") == 0)
return !CurrentCharacter[dialog.Prerequisite.substring(18, 250).replace("()", "").trim()]();
else if (dialog.Prerequisite.indexOf("(") >= 0)
return CommonDynamicFunctionParams(dialog.Prerequisite);
else if (dialog.Prerequisite.substring(0, 1) != "!")
return !!window[CurrentScreen + dialog.Prerequisite.trim()];
else
return !window[CurrentScreen + dialog.Prerequisite.substr(1, 250).trim()];
}
/**
* Checks if the player can play VR games
* @returns {boolean} - Whether or not the player is wearing a VR headset with Gaming type
*/
function DialogHasGamingHeadset() {
return (!KinkyDungeonReady && Player.Effect.includes("KinkyDungeonParty"));
}
/**
* Checks if the player can play VR games
* @returns {boolean} - Whether or not the player is wearing a VR headset with Gaming type
*/
function DialogHasGamingHeadsetReady() {
return (KinkyDungeonReady && Player.Effect.includes("KinkyDungeonParty"));
}
/**
* Checks if the player can watch VR games
* @returns {boolean} - Whether or not the player is wearing a VR headset with Gaming type
*/
function DialogCanWatchKinkyDungeon() {
if (CurrentCharacter) {
if (!CurrentCharacter.Effect.includes("KinkyDungeonParty")) return false;
if (Player.Effect.includes("VR")) return true;
}
return false;
}
/**
* Starts the kinky dungeon game
* @returns {void}
*/
function DialogStartKinkyDungeon() {
if (ArcadeKinkyDungeonLoad()) {
if (CurrentCharacter) {
if (KinkyDungeonPlayerCharacter != CurrentCharacter) {
KinkyDungeonGameRunning = false; // Reset the game to prevent carrying over spectator data
}
KinkyDungeonPlayerCharacter = CurrentCharacter;
if (KinkyDungeonPlayerCharacter != Player && CurrentCharacter.MemberNumber) {
KinkyDungeonGameData = null;
ServerSend("ChatRoomChat", { Content: "RequestFullKinkyDungeonData", Type: "Hidden", Target: CurrentCharacter.MemberNumber });
}
}
DialogGamingReturnScreen = CommonGetScreen();
MiniGameStart("KinkyDungeon", 0, "DialogEndKinkyDungeon");
}
}
/**
* Return to previous room
* @returns {void}
*/
function DialogEndKinkyDungeon() {
if (DialogGamingReturnScreen) {
CommonSetScreen(...DialogGamingReturnScreen);
}
}
/**
* Checks whether the player has a key for the item
* @param {Character} C - The character on whom the item is equipped
* @param {Item} Item - The item that should be unlocked
* @returns {boolean} - Returns true, if the player can unlock the given item with a key, false otherwise
*/
function DialogHasKey(C, Item) {
if (InventoryGetItemProperty(Item, "SelfUnlock") == false && (!Player.CanInteract() || C.IsPlayer())) return false;
if (C.IsOwnedByPlayer() && InventoryAvailable(Player, "OwnerPadlockKey", "ItemMisc") && Item.Asset.Enable) return true;
const lock = InventoryGetLock(Item);
if (lock && lock.Asset.FamilyOnly && Item.Asset.Enable && LogQuery("BlockFamilyKey", "OwnerRule") && Player.IsFullyOwned()) return false;
if (C.IsLoverOfPlayer() && InventoryAvailable(Player, "LoversPadlockKey", "ItemMisc") && Item.Asset.Enable && Item.Property && Item.Property.LockedBy && !Item.Property.LockedBy.startsWith("Owner")) return true;
if (lock && lock.Asset.ExclusiveUnlock) {
// Locks with exclusive access (intricate, high-sec)
const allowedMembers = CommonConvertStringToArray(Item.Property.MemberNumberListKeys);
// High-sec, check if we're in the keyholder list
if (Item.Property.MemberNumberListKeys != null) return allowedMembers.includes(Player.MemberNumber);
// Intricate, check that we added that lock
if (Item.Property.LockMemberNumber == Player.MemberNumber) return true;
}
let UnlockName = /** @type {EffectName} */("Unlock" + Item.Asset.Name);
if ((Item.Property != null) && (Item.Property.LockedBy != null))
UnlockName = /** @type {EffectName} */("Unlock" + Item.Property.LockedBy);
const key = Asset.find(a => InventoryItemHasEffect({ Asset: a }, UnlockName));
if (key && InventoryAvailable(Player, key.Name, key.Group.Name)) {
if (lock != null) {
if (lock.Asset.LoverOnly && !C.IsLoverOfPlayer()) return false;
if (lock.Asset.OwnerOnly && !C.IsOwnedByPlayer()) return false;
if (lock.Asset.FamilyOnly && !C.IsFamilyOfPlayer()) return false;
return true;
} else {
return true;
}
}
return false;
}
/**
* Checks whether the player is able to unlock the provided item on the provided character
* @param {Character} C - The character on whom the item is equipped
* @param {Item} Item - The item that should be unlocked
* @returns {boolean} - Returns true, if the player can unlock the given item, false otherwise
*/
function DialogCanUnlock(C, Item) {
if ((!C.IsPlayer()) && !Player.CanInteract()) return false;
if ((Item != null) && (Item.Property != null) && (Item.Property.LockedBy === "ExclusivePadlock")) return (!C.IsPlayer());
if (LogQuery("KeyDeposit", "Cell")) return false;
if ((Item != null) && (Item.Asset != null) && (Item.Asset.OwnerOnly == true)) return Item.Asset.Enable && C.IsOwnedByPlayer();
if ((Item != null) && (Item.Asset != null) && (Item.Asset.LoverOnly == true)) return Item.Asset.Enable && C.IsLoverOfPlayer();
if ((Item != null) && (Item.Asset != null) && (Item.Asset.FamilyOnly == true)) return Item.Asset.Enable && C.IsFamilyOfPlayer();
return DialogHasKey(C, Item);
}
/**
* Checks whether we can lockpick a lock.
* @param {Character} C
* @param {Item} Item
* @returns {boolean}
*/
function DialogCanPickLock(C, Item) {
return DialogGetPickLockDialog(C, Item) === "PickLock";
}
/**
* Checks whether we can lockpick a lock and return an appropriate dialog key.
* `"PickLock"` will be returned if the lock can be picked.
* @param {Character} C
* @param {Item} Item
* @returns {`PickLock${PickLockAvailability}`}
*/
function DialogGetPickLockDialog(C, Item) {
if (!C || !Item) return "PickLockDisabled";
// Check that the character allows lockpicking
if (!C.IsPlayer() && C.OnlineSharedSettings && C.OnlineSharedSettings.DisablePickingLocksOnSelf)
return "PickLockPermissionsDisabled";
// Check that the lock is accessible, and that we're free to do so
const groupIsBlocked = Item.Asset.Group.IsItem() && InventoryGroupIsBlocked(C, Item.Asset.Group.Name);
const playerHandsAreBlocked = InventoryGroupIsBlocked(Player, "ItemHands");
if (!InventoryAllow(C, Item.Asset) || groupIsBlocked || playerHandsAreBlocked || !InventoryItemIsPickable(Item))
return "PickLockInaccessibleDisabled";
// Check that he player can access their tools.
// Maybe in the future you will be able to hide a lockpick in your panties :>
if (!Player.CanPickLocks())
return "PickLockNoPicksDisabled";
return "PickLock";
}
/**
* Checks whether we can access a lock.
*
* This function is used to enable the locked submenu
*
* @param {Character} C - The character wearing the lock
* @param {Item} Item - The locked item to inspect
* @returns {boolean}
*/
function DialogCanCheckLock(C, Item) {
if (!Item) return false;
// Check that this is a locked item
const lockedBy = InventoryGetItemProperty(Item, "LockedBy");
if (!InventoryItemHasEffect(Item, "Lock", true) || !lockedBy)
return false;
if (!DialogCanInspectLock(Item) && !DialogCanPickLock(C, Item) && !DialogCanUnlock(C, Item))
return false;
return true;
}
/**
* Some specific screens like the movie studio cannot allow the player to use items on herself, return FALSE if it's the case
* @returns {boolean} - Returns TRUE if we allow using items
*/
function DialogAllowItemScreenException() {
if ((CurrentScreen == "MovieStudio") && (MovieStudioCurrentMovie != "")) return false;
return true;
}
/**
* Returns the character's dialog intro
* @param {Character} C - The target character in question
* @returns {string} - The name of the current dialog, if such a dialog exists, any empty string otherwise
*/
function DialogIntro(C) {
const dialog = C?.Dialog.find(d => d.Stage == C.Stage && d.Option == null && d.Result != null && DialogPrerequisite(d));
return dialog?.Result ?? "";
}
/**
* Generic dialog function to leave conversation. De-inititalizes global variables and reverts the
* FocusGroup of the player and the current character to null
* @returns {void} - Nothing
*/
function DialogLeave() {
DialogLeaveFocusItem(false);
// Reset the character's height
if (CurrentCharacter && CharacterAppearanceForceUpCharacter == CurrentCharacter.MemberNumber) {
CharacterAppearanceForceUpCharacter = -1;
CharacterRefresh(CurrentCharacter, false, false);
}
// Reset the mode, selected group & character, exiting the dialog
DialogMenuMode = null;
Object.values(DialogMenuMapping).forEach(obj => obj.Exit());
// Stop sounds & expressions from struggling/swapping items
AudioDialogStop();
// Stop any strugling minigame
if (StruggleMinigameIsRunning()) {
StruggleMinigameStop();
}
// If we're in the two-character dialog, clear their focused group
Player.FocusGroup = null;
if (CurrentCharacter) {
CurrentCharacter.FocusGroup = null;
CurrentCharacter.ClickedOption = null;
CurrentCharacter = null;
}
// Reset the state of the self menu
DialogSelfMenuSelected = null;
for (const preview of DialogSavedExpressionPreviews) {
CharacterDelete(preview, false);
}
DialogSavedExpressionPreviews = [];
DialogFacialExpressionsSelected = -1;
DialogFacialExpressions = [];
// Go controller, go!
ControllerClearAreas();
}
/**
* Generic dialog function to remove a piece of the conversation that's already done
* @returns {void} - Nothing
*/
function DialogRemove() {
const C = CurrentCharacter;
const dialogIndex = C.Dialog.findIndex(dialog => dialog.Stage === C.Stage && dialog.Option === C.ClickedOption && dialog.Option != null && DialogPrerequisite(dialog));
if (dialogIndex !== -1) {
C.Dialog.splice(dialogIndex, 1);
document.querySelector(`.dialog-dialog-button[data-index="${dialogIndex}"]`)?.remove();
document.querySelectorAll(".dialog-dialog-button").forEach((e, i) => e.setAttribute("data-index", i.toString()));
}
}
/**
* Generic dialog function to remove any dialog from a specific group
* @param {string} GroupName - All dialog options are removed from this group
* @returns {void} - Nothing
*/
function DialogRemoveGroup(GroupName) {
const GroupNameUpper = GroupName.trim().toUpperCase();
document.querySelectorAll(`.dialog-dialog-button[data-group="${GroupNameUpper}" i]`).forEach(e => e.remove());
document.querySelectorAll(".dialog-dialog-button").forEach((e, i) => e.setAttribute("data-index", i.toString()));
for (let D = CurrentCharacter.Dialog.length - 1; D >= 0; D--)
if ((CurrentCharacter.Dialog[D].Group != null) && (CurrentCharacter.Dialog[D].Group.trim().toUpperCase() == GroupNameUpper)) {
CurrentCharacter.Dialog.splice(D, 1);
}
}
/**
* Performs a "Back" action through the menu "stack".
*/
function DialogMenuBack() {
switch (DialogMenuMode) {
case "activities":
case "crafted":
case "locked":
case "locking":
DialogChangeMode("items");
break;
case "layering":
Layering.Exit();
break;
case "colorItem":
ItemColorCancelAndExit();
DialogChangeMode("items");
break;
case "colorDefault":
ColorPickerHide();
DialogColorSelect = null;
ElementRemove("InputColor");
DialogChangeMode("items");
break;
case "colorExpression": {
const { mode, group } = DialogExpressionPreviousMode;
DialogChangeMode(mode || "dialog");
DialogChangeFocusToGroup(Player, group);
DialogExpressionPreviousMode = null;
}
break;
case "dialog":
DialogLeave();
break;
case "extended":
case "tighten":
DialogLeaveFocusItem();
break;
case "items":
DialogLeaveItemMenu();
break;
case "permissions":
DialogChangeMode("items");
break;
case "struggle":
if (StruggleMinigameIsRunning()) {
StruggleMinigameStop();
// Move back to the item list only if we automatically started to struggle
if (!DialogStruggleSelectMinigame) {
DialogStruggleSelectMinigame = false;
DialogChangeMode("items");
}
} else {
DialogChangeMode("items");
}
break;
default:
console.trace(`Unknown menu mode "${DialogMenuMode}, resetting`);
DialogLeave();
break;
}
}
/**
* Returns whether the current mode shows items.
*/
function DialogModeShowsInventory() {
return ["items", "activities", "locking", "permissions"].includes(DialogMenuMode);
}
/**
* Helper used to check whether the player is in the Appearance screen
*/
function DialogIsInWardrobe() {
return CurrentScreen === "Appearance";
}
/**
* Leaves the item menu for both characters.
*
* This exits the item-selecting UI and switches back to the current character's dialog options.
*
* @returns {void} - Nothing
*/
function DialogLeaveItemMenu() {
DialogChangeFocusToGroup(CharacterGetCurrent(), null);
}
/**
* Leaves the item menu of the focused item (be it the extended item- or tighten/loosen menu) and
* perform any screen-specific setup for {@link CurrentScreen}.
* @see {@link DialogLeaveFocusItemHandlers} Namespace with helper functions for setting up new screens
* @param {boolean} allowModeChange - Whether to allow automatic changes to the `items` dialog mode.
* Should be set to `false` if any immediate subsequent mode changes or full exits are planned
*/
function DialogLeaveFocusItem(allowModeChange=true) {
if (DialogTightenLoosenItem != null) {
const item = DialogTightenLoosenItem;
TightenLoosenItemExit();
// If we still have a focused item, then we entered tightening through its extended screen
const screenCallback = DialogLeaveFocusItemHandlers.DialogTightenLoosenItem[CurrentScreen];
if (DialogFocusItem) {
DialogChangeMode("extended");
} else if (screenCallback) {
screenCallback(item);
} else if (allowModeChange) {
DialogChangeMode("items");
}
} else if (DialogFocusItem != null) {
const item = DialogFocusItem;
ExtendedItemExit();
if (DialogFocusItem) {
// We're only exiting an extended subscreen
return;
}
const screenCallback = DialogLeaveFocusItemHandlers.DialogFocusItem[CurrentScreen];
if (screenCallback) {
screenCallback(item);
} else if (allowModeChange) {
DialogChangeMode("items");
}
}
}
/**
* Adds the item in the dialog list
* @param {Character} C - The character the inventory is being built for
* @param {Item} item - The item to be added to the inventory
* @param {boolean} isWorn - Should be true, if the item is currently being worn, false otherwise
* @param {DialogSortOrder} [sortOrder] - Defines where in the inventory list the item is sorted
* @returns {void} - Nothing
*/
function DialogInventoryAdd(C, item, isWorn, sortOrder) {
if (DialogMenuMode !== "permissions") {
const asset = item.Asset;
if (!isWorn && !asset.Enable)
return;
// Make sure we do not add owner/lover/family only items for invalid characters, owner/lover locks can be applied on the player by the player for self-bondage
if (asset.OwnerOnly && !isWorn && !C.IsOwnedByPlayer())
if (!C.IsPlayer() || !C.IsOwned() || !asset.IsLock || (C.IsPlayer() && LogQuery("BlockOwnerLockSelf", "OwnerRule")))
return;
if (asset.LoverOnly && !isWorn && !C.IsLoverOfPlayer()) {
if (!asset.IsLock || C.GetLoversNumbers(true).length == 0) return;
if (C.IsPlayer()) {
if (LogQuery("BlockLoverLockSelf", "LoverRule")) return;
}
else if (!C.IsOwnedByPlayer() || LogQueryRemote(C, "BlockLoverLockOwner", "LoverRule")) return;
}
if (asset.FamilyOnly && asset.IsLock && !isWorn && (C.IsPlayer()) && LogQuery("BlockOwnerLockSelf", "OwnerRule")) return;
if (asset.FamilyOnly && (!C.IsPlayer()) && !C.IsFamilyOfPlayer()) return;
if (asset.FamilyOnly && asset.IsLock && !C.IsPlayer() && C.IsOwner()) return;
if (asset.FamilyOnly && asset.IsLock && C.IsPlayer() && (C.IsOwned() === false)) return;
// Do not show keys if they are in the deposit
if (LogQuery("KeyDeposit", "Cell") && InventoryIsKey(item)) return;
// Don't allow gendered assets in the opposite-gender-only space
if (!CharacterAppearanceGenderAllowed(asset)) return;
// Make sure we do not duplicate the item in the list
for (const invItem of DialogInventory) {
if (!DialogAllowItemClick(invItem, item)) {
return;
}
}
}
// Adds the item to the selection list
const inventoryItem = DialogInventoryCreateItem(C, item, isWorn, sortOrder);
if (item.Craft != null) {
inventoryItem.Craft = item.Craft;
if (inventoryItem.SortOrder.charAt(0) == DialogSortOrder.Usable.toString()) inventoryItem.SortOrder = `${DialogSortOrder.PlayerFavoriteUsable}${item.Asset.Description}${item.Craft.Name ?? ""}`;
if (inventoryItem.SortOrder.charAt(0) == DialogSortOrder.Unusable.toString()) inventoryItem.SortOrder = `${DialogSortOrder.PlayerFavoriteUnusable}${item.Asset.Description}${item.Craft.Name ?? ""}`;
}
DialogInventory.push(inventoryItem);
}
/**
* Creates an individual item for the dialog inventory list
* @param {Character} C - The character the inventory is being built for
* @param {Item} item - The item to be added to the inventory
* @param {boolean} isWorn - Should be true if the item is currently being worn, false otherwise
* @param {DialogSortOrder} [sortOrder] - Defines where in the inventory list the item is sorted
* @returns {DialogInventoryItem} - The inventory item
*/
function DialogInventoryCreateItem(C, item, isWorn, sortOrder) {
const asset = item.Asset;
const favoriteStateDetails = DialogGetFavoriteStateDetails(C, asset);
// Determine the sorting order for the item
if (DialogMenuMode !== "permissions") {
if (InventoryBlockedOrLimited(C, item)) {
sortOrder = DialogSortOrder.Blocked;
}
else if (sortOrder == null) {
if (asset.DialogSortOverride != null) {
sortOrder = asset.DialogSortOverride;
} else {
if (InventoryAllow(C, asset, undefined, false) && InventoryChatRoomAllow(asset.Category)) {
sortOrder = favoriteStateDetails.UsableOrder;
} else {
sortOrder = favoriteStateDetails.UnusableOrder;
}
}
}
} else if (sortOrder == null) {
sortOrder = DialogSortOrder.Enabled;
}
// Determine the icons to display in the preview image
/** @type {InventoryIcon[]} */
let icons = [];
if (favoriteStateDetails.Icon) icons.push(favoriteStateDetails.Icon);
icons = icons.concat(DialogGetLockIcon(item, isWorn));
if (InventoryIsAllowedLimited(C, item)) icons.push("AllowedLimited");
icons = icons.concat(DialogGetAssetIcons(asset));
icons = icons.concat(DialogEffectIcons.GetIcons(item));
/** @type {DialogInventoryItem} */
return {
...item,
Worn: isWorn,
Icons: icons,
SortOrder: `${sortOrder}${asset.Description}${item.Craft?.Name ?? ""}`,
Vibrating: isWorn && InventoryItemHasEffect(item, "Vibrating", true),
};
}
/**
* Returns settings for an item based on whether the player and target have favorited it, if any
* @param {Character} C - The targeted character
* @param {Asset} asset - The asset to check favorite settings for
* @param {string} [type=null] - The type of the asset to check favorite settings for
* @returns {FavoriteState} - The details to use for the asset
*/
function DialogGetFavoriteStateDetails(C, asset, type = null) {
const isTargetFavorite = InventoryIsFavorite(C, asset.Name, asset.Group.Name, type);
const isPlayerFavorite = !C.IsPlayer() && InventoryIsFavorite(Player, asset.Name, asset.Group.Name, type);
return DialogFavoriteStateDetails.find(F => F.TargetFavorite == isTargetFavorite && F.PlayerFavorite == isPlayerFavorite);
}
/**
* Return icons representing the asset's current lock state
* @param {Item} item
* @param {boolean} isWorn
*/
function DialogGetLockIcon(item, isWorn) {
/** @type {InventoryIcon[]} */
const icons = [];
if (InventoryItemHasEffect(item, "Lock")) {
if (item.Property && item.Property.LockedBy)
icons.push(item.Property.LockedBy);
else
// One of the default-locked items
icons.push(isWorn ? "Locked" : "Unlocked");
} else if (item.Craft && item.Craft.Lock) {
if (!isWorn || InventoryItemHasEffect(item, "Lock"))
icons.push(item.Craft.Lock);
}
return icons;
}
/**
* Returns a list of icons associated with the asset
* @param {Asset} asset - The asset to get icons for
* @returns {InventoryIcon[]} - A list of icon names
*/
function DialogGetAssetIcons(asset) {
let icons = [];
icons = icons.concat(asset.PreviewIcons);
if (asset.OwnerOnly) icons.push("OwnerOnly");
if (asset.LoverOnly) icons.push("LoverOnly");
if (asset.FamilyOnly) icons.push("FamilyOnly");
if (asset.AllowActivity && asset.AllowActivity.length > 0) icons.push("Handheld");
return icons;
}
/**
* Namespace with functions for getting inventory icons (see {@link DialogEffectIcons.GetIcons})
* @namespace
*/
const DialogEffectIcons = /** @type {const} */({
/** @type {Partial<Record<InventoryIcon, readonly EffectName[]>>} */
Table: {
"GagLight": ["GagVeryLight", "GagLight", "GagEasy"],
"GagNormal": ["GagNormal", "GagMedium"],
"GagHeavy": ["GagHeavy", "GagVeryHeavy"],
"GagTotal": ["GagTotal", "GagTotal2", "GagTotal3", "GagTotal4"],
"DeafLight": ["DeafLight"],
"DeafNormal": ["DeafNormal", "DeafHeavy"],
"DeafHeavy": ["DeafTotal"],
"BlindLight": ["BlindLight"],
"BlindNormal": ["BlindNormal", "BlindHeavy"],
"BlindHeavy": ["BlindTotal"],
},
/**
* Return icons for each "interesting" effect on the item.
* @param {Item} item
* @returns {InventoryIcon[]} - A list of icon names.
*/
GetIcons: function(item) {
const effects = new Set([...item.Asset.Effect, ...(item.Property?.Effect ?? [])]);
const craftingProperty = item.Craft?.Property;
return DialogEffectIcons.GetEffectIcons(effects, craftingProperty);
},
/** @type {(effects: Iterable<EffectName>, prop?: CraftingPropertyType) => null | InventoryIcon[]} */
GetEffectIcons: function (effects, prop) {
/** @type {InventoryIcon[]} */
const icons = [];
for (const effect of effects) {
switch (effect) {
case "Block":
case "Freeze":
icons.push(effect);
continue;
}
const icon = (
DialogEffectIcons._GetGagIcon(effect, prop)
|| DialogEffectIcons._GetBlindIcon(effect, prop)
|| DialogEffectIcons._GetDeafIcon(effect)
);
if (icon) {
icons.push(icon);
}
}
return icons;
},
/** @type {(effect: EffectName, prop?: CraftingPropertyType) => null | InventoryIcon} */
_GetGagIcon(effect, prop) {
let level = SpeechGagLevelLookup[/** @type {GagEffectName} */(effect)];
if (level === undefined) {
return null;
} else if (prop === "Small") {
level -= 2;
} else if (prop === "Large") {
level += 2;
}
return DialogEffectIcons._GagLevelToIcon(level);
},
/** @type {(effect: EffectName, prop?: CraftingPropertyType) => null | InventoryIcon} */
_GetBlindIcon(effect, prop) {
let level = CharacterBlindLevels.get(/** @type {BlindEffectName} */(effect));
if (level === undefined) {
return null;
} else if (prop === "Thin") {
level -= 2;
} else if (prop === "Thick") {
level += 2;
}
return DialogEffectIcons._BlindLevelToIcon(level);
},
/** @type {(effect: EffectName) => undefined | InventoryIcon} */
_GetDeafIcon(effect) {
/** @type {InventoryIcon[]} */
const keys = ["DeafLight", "DeafNormal", "DeafHeavy"];
return keys.find(k => DialogEffectIcons.Table[k].includes(effect));
},
/** @type {(level?: number) => null | InventoryIcon} */
_GagLevelToIcon: function (level) {
if (!level || level < SpeechGagLevelLookup.GagVeryLight) {
return null;
} else if (level <= SpeechGagLevelLookup.GagEasy) {
return "GagLight";
} else if (level <= SpeechGagLevelLookup.GagMedium) {
return "GagNormal";
} else if (level <= SpeechGagLevelLookup.GagVeryHeavy) {
return "GagHeavy";
} else {
return "GagTotal";
}
},
/** @type {(level?: number) => null | InventoryIcon} */
_BlindLevelToIcon: function (level) {
if (!level || level < CharacterBlindLevels.get("BlindLight")) {
return null;
} else if (level <= CharacterBlindLevels.get("BlindLight")) {
return "BlindLight";
} else if (level <= CharacterBlindLevels.get("BlindHeavy")) {
return "BlindNormal";
} else {
return "BlindHeavy";
}
},
});
/**
* Some special screens can always allow you to put on new restraints. This function determines, if this is possible
* @returns {boolean} - Returns trues, if it is possible to put on new restraints.
*/
function DialogAlwaysAllowRestraint() {
return (CurrentScreen == "Photographic" || CurrentScreen == "Crafting");
}
/**
* Checks whether the player can use a remote on the given character and item
* @param {Character} C - the character that the item is equipped on
* @param {Item} Item - the item to check for remote usage against
* @return {VibratorRemoteAvailability} - Returns the status of remote availability: Available, NoRemote, NoLoversRemote, RemotesBlocked, CannotInteract, NoAccess, InvalidItem
*/
function DialogCanUseRemoteState(C, Item) {
// Can't use remotes if there is no item, or if the item doesn't have the needed effects.
if (!Item || !InventoryItemHasEffect(Item, "UseRemote")) return "InvalidItem";
// Can't use remotes if the player cannot interact with their hands
if (!Player.CanInteract()) return "CannotInteract";
// Can't use remotes on self if the player is owned and their remotes have been blocked by an owner rule
if (C.IsPlayer() && Player.IsFullyOwned() && LogQuery("BlockRemoteSelf", "OwnerRule")) return "RemotesBlocked";
if (Item.Asset.LoverOnly) {
if (!C.IsLoverOfPlayer()) {
return "NoAccess";
} else if (!InventoryAvailable(Player, "LoversVibratorRemote", "ItemVulva")) {
return "NoLoversRemote";
} else {
return "Available";
}
} else {
// Otherwise, the player must have a vibrator remote and some items can block remotes
if (C.Effect.indexOf("BlockRemotes") >= 0) {
return "RemotesBlocked";
}
if (!InventoryAvailable(Player, "VibratorRemote", "ItemVulva")) {
return "NoRemote";
}
if (LogQuery("BlockRemote", "OwnerRule")) {
return "NoRemoteOwnerRuleActive";
}
return "Available";
}
}
/**
* Checks whether the player can color the given item on the given character
* @param {Character} C - The character on whom the item is equipped
* @param {Item} Item - The item to check the player's ability to color against
* @returns {boolean} - TRUE if the player is able to color the item, FALSE otherwise
*/
function DialogCanColor(C, Item) {
if (!Item || Item.Asset.ColorableLayerCount === 0) {
return false;
}
const canColor = InventoryItemHasEffect(Item, "Lock", true) ? DialogCanUnlock(C, Item) : Player.CanInteract();
return canColor || DialogAlwaysAllowRestraint();
}
/**
* Checks whether a lock can be inspected while blind.
* @param {Item} Item - The locked item
* @returns {boolean}
*/
function DialogCanInspectLock(Item) {
if (!Item) return false;
const lockedBy = InventoryGetItemProperty(Item, "LockedBy");
return !Player.IsBlind() || ["SafewordPadlock", "CombinationPadlock"].includes(lockedBy);
}
/**
* Builds the possible dialog activity options based on the character settings
* @param {Character} C - The character for which to build the activity dialog options
* @param {boolean} reload - Perform a {@link DialogMenu.Reload} hard reset of the active `activities` mode
* @return {void} - Nothing
*/
function DialogBuildActivities(C, reload=true) {
if (C.FocusGroup == null) {
DialogActivity = [];
} else {
DialogActivity = ActivityAllowedForGroup(C, C.FocusGroup.Name);
}
if (reload) {
switch (DialogMenuMode) {
case "activities":
DialogMenuMapping.activities.Reload(null, { reset: true, resetDialogItems: false });
break;
}
}
}
/**
* Build the buttons in the top menu
* @param {Character} C - The character for whom the dialog is prepared
* @returns {void} - Nothing
*/
function DialogMenuButtonBuild(C) {
DialogMenuButton = [];
// Hide "Exit" button for the screens where
if (!["colorExpression", "colorItem"].includes(DialogMenuMode))
DialogMenuButton = ["Exit"];
// There's no group focused, hence no menu to draw
if (C.FocusGroup == null) return;
/** The item in the current slot */
const Item = InventoryGet(C, C.FocusGroup.Name);
const ItemBlockedOrLimited = !!Item && InventoryBlockedOrLimited(C, Item);
const IsItemLocked = InventoryItemHasEffect(Item, "Lock", true);
const IsGroupBlocked = InventoryGroupIsBlocked(C);
if (DialogMenuMode === "colorDefault") {
DialogMenuButton.push("ColorCancel");
DialogMenuButton.push("ColorSelect");
}
else if (DialogMenuMode === "struggle") {
// Struggle has no additional buttons
}
else if (DialogMenuMode === "locked") {
// If the item isn't locked, there are no more buttons to add
const Lock = InventoryGetLock(Item);
if (!IsItemLocked || !Lock) return;
const LockBlockedOrLimited = InventoryBlockedOrLimited(C, Lock) || ItemBlockedOrLimited;
if (
Item != null
&& !IsGroupBlocked
&& DialogCanUnlock(C, Item)
&& (
(!Player.IsBlind() && (!C.IsPlayer() || C.CanInteract()))
|| (C.IsPlayer() && !Player.CanInteract() && InventoryItemHasEffect(Item, "Block", true))
)
) {
DialogMenuButton.push("Unlock");
}
if (InventoryItemIsPickable(Item)) {
DialogMenuButton.push(DialogGetPickLockDialog(C, Item));
}
if (DialogCanInspectLock(Item)) {
DialogMenuButton.push(LockBlockedOrLimited ? "InspectLockDisabled" : "InspectLock");
}
}
else if (
DialogMenuMode === "crafted"
|| DialogMenuMode === "layering"
|| DialogMenuMode === "activities"
|| DialogMenuMode === "locking"
) {
// No buttons there
}
// Pushes all valid main buttons, based on if the player is restrained, has a blocked group, has the key, etc.
else {
if (DialogMenuMode === "permissions") {
DialogMenuButton.push("NormalMode");
return;
}
if (IsItemLocked && DialogCanUnlock(C, Item) && !IsGroupBlocked && (!C.IsPlayer() || Player.CanInteract()))
DialogMenuButton.push("Remove");
if (
!IsGroupBlocked
&& Item?.Asset.AllowTighten
&& (Player.CanInteract() && (!IsItemLocked || DialogCanUnlock(C, Item)))
) {
DialogMenuButton.push("TightenLoosen");
}
if (DialogCanCheckLock(C, Item))
DialogMenuButton.push("LockMenu");
if ((Item != null) && C.IsPlayer() && (!Player.CanInteract() || (IsItemLocked && !DialogCanUnlock(C, Item))) && (DialogMenuButton.indexOf("Unlock") < 0) && !IsGroupBlocked)
DialogMenuButton.push("Struggle");
if ((Item != null) && (Item.Craft != null))
DialogMenuButton.push("Crafting");
// If the Asylum GGTS controls the item, we show a disabled button and hide the other buttons
if (AsylumGGTSControlItem(C, Item)) {
DialogMenuButton.push("GGTSControl");
} else if (Item != null) {
// There's an item in the slot
if (!IsItemLocked && Player.CanInteract() && !IsGroupBlocked) {
if (InventoryDoesItemAllowLock(Item)) {
DialogMenuButton.push(ItemBlockedOrLimited ? "LockDisabled" : "Lock");
}
if (InventoryItemHasEffect(Item, "Mounted", true))
DialogMenuButton.push("Dismount");
else if (InventoryItemHasEffect(Item, "Enclose", true))
DialogMenuButton.push("Escape");
else
DialogMenuButton.push("Remove");
}
const canUseRemoteState = DialogCanUseRemoteState(C, Item);
if (
Item.Asset.Extended &&
(Player.CanInteract() || DialogAlwaysAllowRestraint() || Item.Asset.AlwaysInteract) &&
(!IsGroupBlocked || Item.Asset.AlwaysExtend) &&
(!Item.Asset.OwnerOnly || (C.IsOwnedByPlayer())) &&
(!Item.Asset.LoverOnly || (C.IsLoverOfPlayer())) &&
(!Item.Asset.FamilyOnly || (C.IsFamilyOfPlayer())) &&
canUseRemoteState === "InvalidItem"
) {
DialogMenuButton.push(ItemBlockedOrLimited ? "UseDisabled" : "Use");
}
if (!DialogMenuButton.includes("Use") && canUseRemoteState !== "InvalidItem") {
/** @type {DialogMenuButton} */
let button = null;
switch (canUseRemoteState) {
case "Available":
button = ItemBlockedOrLimited ? "RemoteDisabled" : "Remote";
break;
default:
button = `RemoteDisabledFor${canUseRemoteState}`;
break;
}
DialogMenuButton.push(button);
}
}
// Color selection
if (DialogCanColor(C, Item)) {
DialogMenuButton.push(ItemBlockedOrLimited ? "ColorPickDisabled" : "ColorChange");
} else {
DialogMenuButton.push("ColorDefault");
}
if (DialogActivity.length > 0) DialogMenuButton.push("Activity");
// Item permission enter/exit
if (C.IsPlayer()) {
DialogMenuButton.push("PermissionMode");
}
if (Item != null && !C.IsNpc()) {
DialogMenuButton.push("Layering");
}
}
}
/**
* Sort the inventory list by the global variable SortOrder (a fixed number & current language description)
* @returns {void} - Nothing
*/
function DialogInventorySort() {
DialogInventory.sort((a, b) => a.SortOrder.localeCompare(b.SortOrder, undefined, { numeric: true, sensitivity: 'base' }));
}
/**
* Returns TRUE if the crafted item can be used on a character, validates for owners and lovers
* @param {Character} C - The character whose inventory must be built
* @param {CraftingItem} Craft - The crafting properties of the item
* @param {Asset} A - The items asset
* @returns {Boolean} - TRUE if we can use it
*/
function DialogCanUseCraftedItem(C, Craft, A) {
// Validates the craft asset
if (!C || !Craft || !A) return false;
if (A.OwnerOnly && !C.IsOwnedByPlayer()) return false;
if (A.LoverOnly && !(C.IsLoverOfPlayer() || C.IsOwnedByPlayer())) return false;
if (A.FamilyOnly && !(C.IsFamilyOfPlayer() || C.IsLoverOfPlayer() || C.IsOwnedByPlayer())) return false;
/** @type {undefined | Asset} */
const lock = AssetLocks[/** @type {AssetLockType} */(Craft.Lock)];
if (lock != null) {
if (lock.OwnerOnly && !DialogCanUseOwnerLockOn(C)) return false;
if (lock.LoverOnly && !DialogCanUseLoverLockOn(C)) return false;
if (lock.FamilyOnly && !DialogCanUseFamilyLockOn(C)) return false;
}
return true;
}
/**
* Returns TRUE if the player can use owner locks on the target character
* @param {Character} target - The target to (potentially) lock
* @returns {Boolean} - TRUE if the player can use owner locks on the target, FALSE otherwise
*/
function DialogCanUseOwnerLockOn(target) {
return target.IsPlayer()
? (target.IsOwned() && !LogQuery("BlockOwnerLockSelf", "OwnerRule"))
: target.IsOwnedByPlayer();
}
/**
* Returns TRUE if the player can use lover locks on the target character
* @param {Character} target - The target to (potentially) lock
* @returns {Boolean} - TRUE if the player can use lover locks on the target, FALSE otherwise
*/
function DialogCanUseLoverLockOn(target) {
return target.IsPlayer()
? (target.GetLoversNumbers(true).length > 0 && !LogQuery("BlockLoverLockSelf", "LoverRule"))
: target.IsOwnedByPlayer()
? !LogQueryRemote(target, "BlockLoverLockOwner", "LoverRule")
: target.IsLoverOfPlayer();
}
/**
* Returns TRUE if the player can use family locks on the target character
* @param {Character} target - The target to (potentially) lock
* @returns {Boolean} - TRUE if the player can use family locks on the target, FALSE otherwise
*/
function DialogCanUseFamilyLockOn(target) {
return target.IsPlayer()
? (target.IsOwned() && !LogQuery("BlockOwnerLockSelf", "OwnerRule"))
: target.IsOwner() // Owner is in family, but you can't use family locks on them
? false
: target.IsFamilyOfPlayer();
}
/**
* Build the inventory listing for the dialog which is what's equipped,
* the player's inventory and the character's inventory for that group
* @param {Character} C - The character whose inventory must be built
* @param {boolean} [resetOffset=false] - The offset to be at, if specified.
* @param {boolean} [locks=false] - If TRUE we build a list of locks instead.
* @param {boolean} reload - Perform a {@link DialogMenu.Reload} hard reset of the active `items`, `locking` or `permissions` mode
* @returns {void} - Nothing
*/
function DialogInventoryBuild(C, resetOffset=false, locks=false, reload=true) {
if (resetOffset)
DialogInventoryOffset = 0;
DialogInventory = [];
// Make sure there's a focused group
if (C.FocusGroup == null) return;
if (locks) {
Asset.filter(a => a.IsLock && InventoryAvailable(Player, a.Name, a.Group.Name)).forEach(a => DialogInventoryAdd(C, { Asset: a }, false));
DialogInventoryOffset = Math.max(0, Math.min(DialogInventory.length, DialogInventoryOffset));
DialogInventorySort();
return;
}
const CurItem = C.Appearance.find(A => A.Asset.Group.Name == C.FocusGroup.Name && A.Asset.DynamicAllowInventoryAdd(C));
// In item permission mode we add all the enable items except the ones already on, unless on Extreme difficulty
if (DialogMenuMode === "permissions") {
for (const A of C.FocusGroup.Asset) {
if (!A.Enable)
continue;
if (A.Wear) {
const isWorn = CurItem && CurItem.Asset.Name === A.Name && CurItem.Asset.Group.Name === A.Group.Name;
DialogInventoryAdd(Player, { Asset: A }, isWorn, DialogSortOrder.Enabled);
} else if (A.IsLock) {
const LockIsWorn = InventoryCharacterIsWearingLock(C, /** @type {AssetLockType} */ (A.Name));
DialogInventoryAdd(Player, { Asset: A }, LockIsWorn, DialogSortOrder.Enabled);
}
}
} else {
// First, we add anything that's currently equipped
if (CurItem)
DialogInventoryAdd(C, CurItem, true, DialogSortOrder.Enabled);
// Second, we add everything from the victim inventory
for (const I of C.Inventory)
if ((I.Asset != null) && (I.Asset.Group.Name == C.FocusGroup.Name) && I.Asset.DynamicAllowInventoryAdd(C))
DialogInventoryAdd(C, I, false);
// Third, we add everything from the player inventory if the player isn't the victim
if (!C.IsPlayer())
for (const I of Player.Inventory)
if ((I.Asset != null) && (I.Asset.Group.Name == C.FocusGroup.Name) && I.Asset.DynamicAllowInventoryAdd(C))
DialogInventoryAdd(C, I, false);
// Fourth, we add all free items (especially useful for clothes), or location-specific always available items
for (const A of Asset)
if (A.Group.Name === C.FocusGroup.Name && A.DynamicAllowInventoryAdd(C))
if (InventoryAvailable(C, A.Name, A.Group.Name))
DialogInventoryAdd(C, { Asset: A }, false);
// Fifth, we add all crafted items for the player that matches that slot
for (const Craft of (Player.Crafting ?? [])) {
if (Craft == null || Craft.Disabled) {
continue;
}
for (const Asset of (CraftingAssets[Craft.Item] ?? [])) {
if (Asset.Group.Name === C.FocusGroup.Name && DialogCanUseCraftedItem(C, Craft, Asset)) {
DialogInventoryAdd(C, { Asset, Craft }, false);
}
}
}
// Sixth. we add all crafted items from the character that matches that slot
if (!C.IsPlayer() && !C.IsNpc()) {
const Crafting = CraftingDecompressServerData(C.Crafting);
for (const Craft of Crafting) {
if (Craft == null || Craft.Private) {
continue;
}
Craft.MemberName = CharacterNickname(C);
Craft.MemberNumber = C.MemberNumber;
for (const Asset of (CraftingAssets[Craft.Item] ?? [])) {
if (Asset.Group.Name === C.FocusGroup.Name && DialogCanUseCraftedItem(C, Craft, Asset)) {
DialogInventoryAdd(C, { Asset, Craft }, false);
}
}
}
}
// Seventh, if the player is using the online map room and is located on an object tile, she can use that object
if ((CurrentScreen == "ChatRoom") && ChatRoomMapViewIsActive() && Player.MapData) {
let Obj = ChatRoomMapViewGetObjectAtPos(Player.MapData.Pos.X, Player.MapData.Pos.Y);
if ((Obj != null) && (Obj.AssetName != null) && (Obj.AssetGroup != null))
for (const A of Asset)
if ((A.Name === Obj.AssetName) && (A.Group.Name === Obj.AssetGroup) && (A.Group.Name === C.FocusGroup.Name))
DialogInventoryAdd(C, { Asset: A }, false);
}
}
DialogInventoryOffset = Math.max(0, Math.min(DialogInventory.length, DialogInventoryOffset));
DialogInventorySort();
if (reload) {
switch (DialogMenuMode) {
case "items":
case "locking":
case "permissions":
DialogMenuMapping[DialogMenuMode]?.Reload(null, { reset: true, resetDialogItems: false });
break;
}
}
}
/**
* Create a stringified list of the group and the assets currently in the dialog inventory
* @param {Character} C - The character the dialog inventory has been built for
* @returns {string} - The list of assets as a string
*/
function DialogInventoryStringified(C) {
return (C.FocusGroup ? C.FocusGroup.Name : "") + (DialogInventory ? JSON.stringify(DialogInventory.map(I => I.Asset.Name).sort()) : "");
}
/**
* Build the initial state of the selection available in the facial expressions menu
* @returns {void} - Nothing
*/
function DialogFacialExpressionsBuild() {
DialogFacialExpressions = [];
for (let I = 0; I < Player.Appearance.length; I++) {
const PA = Player.Appearance[I];
const ExpressionList = [...(PA.Asset.Group.AllowExpression || [])];
if (!ExpressionList.length || PA.Asset.Group.Name == "Eyes2") continue;
// Make sure the default expression always appear
if (!ExpressionList.includes(null)) ExpressionList.unshift(null);
// If there are no allowed expression, skip the group entirely
if (!ExpressionList.some(expr => CharacterIsExpressionAllowed(Player, PA, expr))) continue;
/** @type {ExpressionItem} */
const Item = {
Appearance: PA,
Group: /** @type {ExpressionGroupName} */(PA.Asset.Group.Name),
CurrentExpression: (PA.Property == null) ? null : PA.Property.Expression,
ExpressionList: ExpressionList,
};
DialogFacialExpressions.push(Item);
}
// Temporary (?) solution to make the facial elements appear in a more logical order, as their alphabetical order currently happens to match up
DialogFacialExpressions = DialogFacialExpressions.sort(function (a, b) {
return a.Appearance.Asset.Group.Name < b.Appearance.Asset.Group.Name ? -1 : a.Appearance.Asset.Group.Name > b.Appearance.Asset.Group.Name ? 1 : 0;
});
}
/**
* Saves the expressions to a slot
* @param {number} Slot - Index of saved expression (0 to 4)
*/
function DialogFacialExpressionsSave(Slot) {
Player.SavedExpressions[Slot] = [];
for (let x = 0; x < DialogFacialExpressions.length; x++) {
Player.SavedExpressions[Slot].push({ Group: DialogFacialExpressions[x].Group, CurrentExpression: DialogFacialExpressions[x].CurrentExpression });
}
if (Player.SavedExpressions[Slot].every(expression => !expression.CurrentExpression))
Player.SavedExpressions[Slot] = null;
ServerAccountUpdate.QueueData({ SavedExpressions: Player.SavedExpressions });
DialogBuildSavedExpressionsMenu();
}
/**
* Loads expressions from a slot
* @param {number} Slot - Index of saved expression (0 to 4)
*/
function DialogFacialExpressionsLoad(Slot) {
const expressions = Player.SavedExpressions && Player.SavedExpressions[Slot];
if (expressions != null) {
expressions.forEach(e => CharacterSetFacialExpression(Player, e.Group, e.CurrentExpression));
DialogFacialExpressionsBuild();
}
}
/**
* Builds the savedexpressions menu previews.
* @returns {void} - Nothing
*/
function DialogBuildSavedExpressionsMenu() {
const ExcludedGroups = ["Mask"];
const AppearanceItems = Player.Appearance.filter(A => A.Asset.Group.Category === "Appearance" && !ExcludedGroups.includes(A.Asset.Group.Name));
const BaseAppearance = AppearanceItems.filter(A => !A.Asset.Group.AllowExpression);
const ExpressionGroups = AppearanceItems.filter(A => A.Asset.Group.AllowExpression);
Player.SavedExpressions.forEach((expression, i) => {
if (expression) {
const PreviewCharacter = CharacterLoadSimple("SavedExpressionPreview-" + i);
PreviewCharacter.Appearance = BaseAppearance.slice();
ExpressionGroups.forEach(I =>
PreviewCharacter.Appearance.push({
Asset: I.Asset,
Color: I.Color,
Property: I.Property ? { ...I.Property } : undefined,
})
);
for (let x = 0; x < expression.length; x++) {
CharacterSetFacialExpression(PreviewCharacter, expression[x].Group, expression[x].CurrentExpression);
}
CharacterRefresh(PreviewCharacter);
DialogSavedExpressionPreviews[i] = PreviewCharacter;
}
});
}
/**
* Draws the savedexpressions menu
* @returns {void} - Nothing
*/
function DialogDrawSavedExpressionsMenu() {
DrawText(InterfaceTextGet("SavedExpressions"), 210, 90, "White", "Black");
if ((!DialogSavedExpressionPreviews || !DialogSavedExpressionPreviews.length) && Player.SavedExpressions.some(expression => expression != null))
DialogBuildSavedExpressionsMenu();
for (let x = 0; x < 5; x++) {
if (Player.SavedExpressions[x] == null) {
DrawText(InterfaceTextGet("SavedExpressionsEmpty"), 160, 216 + (x * 170), "White", "Black");
} else {
const PreviewCanvas = DrawCharacterSegment(DialogSavedExpressionPreviews[x], 100, 30, 300, 220);
MainCanvas.drawImage(PreviewCanvas, 20, 92 + (x * 175), 260, 190);
}
DrawButton(290, 160 + (x * 170), 120, 50, InterfaceTextGet("SavedExpressionsSave"), "White");
DrawButton(290, 220 + (x * 170), 120, 50, InterfaceTextGet("SavedExpressionsLoad"), "White");
}
}
/** Handles clicks in the savedexpressions menu
* @returns {void} - Nothing
*/
function DialogClickSavedExpressionsMenu() {
if (MouseXIn(290, 120)) {
for (let x = 0; x < 5; x++) {
if (MouseYIn(160 + (x * 170), 50)) {
DialogFacialExpressionsSave(x);
}
}
}
if (MouseXIn(290, 120)) {
for (let x = 0; x < 5; x++) {
if (MouseYIn(220 + (x * 170), 50)) {
DialogFacialExpressionsLoad(x);
}
}
}
}
/**
* Build the initial state of the pose menu
* @param {Character} [C] The character for whom {@link DialogActivePoses} whill be constructed; defaults to {@link CurrentCharacter}
* @returns {void} - Nothing
*/
function DialogLoadPoseMenu(C=CurrentCharacter) {
DialogActivePoses = [];
// Gather all unique categories from poses
const PoseCategories = new Set(PoseFemale3DCG
.filter(P => (P.AllowMenu || P.AllowMenuTransient && PoseAvailable(C, P.Category, P.Name)))
.map(P => P.Category)
);
// Add their pose in order so they're grouped together
PoseCategories.forEach(Category => {
DialogActivePoses.push(PoseFemale3DCG.filter(P => (P.AllowMenu || P.AllowMenuTransient && PoseAvailable(C, P.Category, P.Name)) && P.Category == Category));
});
}
/**
* Handles the Click events in the Dialog Screen
* @returns {boolean} - Whether a button was clicked
*/
function DialogMenuButtonClick() {
// Hack because those panes handle their menu icons themselves
if (["colorExpression", "colorItem", "extended", "layering", "tighten"].includes(DialogMenuMode)) return false;
// Gets the current character and item
/** The focused character */
const C = CharacterGetCurrent();
/** The focused item */
const Item = C.FocusGroup ? InventoryGet(C, C.FocusGroup.Name) : null;
// Finds the current icon
for (let I = 0; I < DialogMenuButton.length; I++) {
if (MouseIn(1885 - I * 110, 15, 90, 90)) {
const button = DialogMenuButton[I];
// Exit Icon - Go back one level in the menu
if (button === "Exit") {
DialogMenuBack();
return true;
}
// Use Icon - Pops the item extension for the focused item
else if (button === "Use" && Item) {
DialogExtendItem(Item);
return true;
}
// Remote Icon - Pops the item extension
else if (button === "Remote" && DialogCanUseRemoteState(C, Item) === "Available") {
DialogExtendItem(Item);
return true;
}
// Lock Icon - Rebuilds the inventory list with locking items
else if (button === "Lock") {
if (Item && InventoryDoesItemAllowLock(Item)) {
DialogChangeMode("locking");
}
return true;
}
// Unlock Icon - If the item is padlocked, we immediately unlock. If not, we start the struggle progress.
else if (button === "Unlock" && Item) {
// Check that this is not one of the sticky-locked items
const isNotStickyLock = InventoryItemHasEffect(Item, "Lock", true) && !InventoryItemHasEffect(Item, "Lock", false);
if (C.FocusGroup.IsItem() && isNotStickyLock && (!C.IsPlayer() || C.CanInteract())) {
InventoryUnlock(C, C.FocusGroup.Name, false);
if (ChatRoomPublishAction(C, "ActionUnlock", Item, null)) {
DialogLeave();
} else {
DialogChangeMode("items");
}
} else
DialogStruggleStart(C, "ActionUnlock", Item, null);
return true;
}
// Tighten/Loosen Icon - Opens the sub menu
else if (button === "TightenLoosen" && Item) {
DialogSetTightenLoosenItem(Item);
return true;
}
// Remove/Struggle Icon - Starts the struggling mini-game (can be impossible to complete)
else if (
(button === "Remove" || button === "Struggle" || button === "Dismount" || button === "Escape")
&& Item) {
/** @type {DialogStruggleActionType} */
let action = "ActionRemove";
if (InventoryItemHasEffect(Item, "Lock")) {
action = "ActionUnlockAndRemove";
} else if (C.IsPlayer()) {
action = `Action${button}`;
}
DialogStruggleStart(C, action, Item, null);
return true;
}
// PickLock Icon - Starts the lockpicking mini-game
else if (button === "PickLock" && Item) {
StruggleMinigameStart(C, "LockPick", Item, null, DialogStruggleStop);
DialogChangeMode("struggle");
DialogMenuButtonBuild(C);
return true;
}
// When the player inspects a lock
else if (button === "InspectLock" && Item) {
const Lock = InventoryGetLock(Item);
if (Lock != null) DialogExtendItem(Lock, Item);
return true;
}
// Color picker Icon - Select a default color to batch-apply on items
else if (button === "ColorDefault") {
DialogChangeMode("colorDefault");
ElementCreateInput("InputColor", "text", (DialogColorSelect != null) ? DialogColorSelect.toString() : "");
return true;
// Starts picking colors for the item, keeps the original color and shows it at the bottom
} else if (button === "ColorChange" && Item != null) {
const originalColor = Item.Color;
DialogChangeMode("colorItem");
ItemColorLoad(C, Item, 1300, 25, 675, 950);
ItemColorOnExit((save) => {
DialogChangeMode("items");
if (save && !CommonColorsEqual(originalColor, Item.Color)) {
if (C.IsPlayer()) ServerPlayerAppearanceSync();
ChatRoomPublishAction(C, "ActionChangeColor", Object.assign({}, Item, { Color: originalColor }), Item);
}
});
return true;
}
// When the user selects a color, applies it to the item
else if (button === "ColorSelect" && CommonIsColor(ElementValue("InputColor"))) {
DialogColorSelect = ElementValue("InputColor");
ColorPickerHide();
ElementRemove("InputColor");
DialogChangeMode("items");
return true;
}
// When the user cancels out of color picking, we recall the original color
else if (button === "ColorCancel") {
DialogColorSelect = null;
ColorPickerHide();
ElementRemove("InputColor");
DialogChangeMode("items");
return true;
}
// When the user selects the lock menu, we enter
else if (Item && button === "LockMenu") {
DialogChangeMode("locked");
return true;
}
// When the user selects the lock menu, we enter
else if (Item && button === "Crafting") {
DialogChangeMode("crafted");
return true;
}
// When the user wants to select a sexual activity to perform
else if (button === "Activity") {
DialogChangeMode("activities");
return true;
}
// When we enter item permission mode, we rebuild the inventory to set permissions
else if (button === "PermissionMode") {
DialogChangeMode("permissions");
return true;
}
// When we leave item permission mode, we upload the changes for everyone in the room
else if (button === "NormalMode") {
DialogChangeMode("items");
return true;
}
else if (Item && button === "Layering") {
DialogChangeMode("layering");
return true;
}
}
}
return false;
}
/**
* Publishes the item action to the local chat room or the dialog screen
* @param {Character} C - The character who is the actor in this action
* @param {string} Action - The action performed
* @param {Item} ClickItem - The item that is used
* @returns {void} - Nothing
*/
function DialogPublishAction(C, Action, ClickItem) {
// Publishes the item result
if ((CurrentScreen == "ChatRoom") && !InventoryItemHasEffect(ClickItem)) {
if (ChatRoomPublishAction(C, Action, null, ClickItem))
DialogLeave();
}
else if (C.IsNpc()) {
let Line = ClickItem.Asset.Group.Name + ClickItem.Asset.DynamicName(Player);
let D = DialogFind(C, Line, null, false);
if (D != "") {
C.CurrentDialog = D;
DialogLeaveItemMenu();
}
}
}
/**
* Returns TRUE if the clicked item can be processed, make sure it's not the same item as the one already used
* @param {Item} CurrentItem - The item currently equiped
* @param {Item} ClickItem - The clicked item
* @returns {boolean} - TRUE when we can process
*/
function DialogAllowItemClick(CurrentItem, ClickItem) {
if (CurrentItem == null) return true;
if (CurrentItem.Asset.Name != ClickItem.Asset.Name) return true;
if ((CurrentItem.Craft == null) && (ClickItem.Craft != null)) return true;
if ((CurrentItem.Craft != null) && (ClickItem.Craft == null)) return true;
if ((CurrentItem.Craft != null) && (ClickItem.Craft != null) && (CurrentItem.Craft.Name != ClickItem.Craft.Name)) return true;
return false;
}
/**
* Handles `permissions`-mode clicks on an item
* @param {DialogInventoryItem} ClickItem - The item that is clicked
* @param {null | Item} CurrentItem
* @returns {ItemPermissionMode} - Nothing
*/
function DialogPermissionsClick(ClickItem, CurrentItem=null) {
const worn = (ClickItem.Worn || (CurrentItem && (CurrentItem.Asset.Name == ClickItem.Asset.Name)));
return DialogInventoryTogglePermission(ClickItem, worn);
}
/**
* Handles `locking`-mode clicks on an item
* @param {DialogInventoryItem} ClickedLock - The item that is clicked
* @param {Character} C
* @param {null | Item} CurrentItem
*/
function DialogLockingClick(ClickedLock, C, CurrentItem=null) {
InventoryLock(C, CurrentItem, ClickedLock, Player.MemberNumber);
IntroductionJobProgress("DomLock", ClickedLock.Asset.Name, true);
if (ChatRoomPublishAction(C, "ActionAddLock", CurrentItem, ClickedLock)) {
DialogLeave();
} else {
DialogChangeMode("items");
}
}
/**
* Handles `items`-mode clicks on an item
* @param {DialogInventoryItem} ClickItem - The item that is clicked
* @param {Character} C - The target character
* @param {null | Item} CurrentItem - The equipped item (if any)
*/
function DialogItemClick(ClickItem, C, CurrentItem=null) {
// We're dealing with a previously equipped extended item; open the extended item menu
if (CurrentItem?.Asset.Extended && !DialogAllowItemClick(CurrentItem, ClickItem)) {
DialogExtendItem(CurrentItem);
return;
}
// Special-casing for items that cannot actually be worn
if (!ClickItem.Asset.Wear) {
if (InventoryItemHasEffect(ClickItem, /** @type {EffectName} */(`Unlock${CurrentItem?.Asset.Name}`))) {
// Unlock an item if one clicks the key
DialogStruggleStart(C, "ActionUnlock", CurrentItem, null);
} else if (ClickItem.Asset.Name === "VibratorRemote" || ClickItem.Asset.Name === "LoversVibratorRemote") {
// The vibrating egg remote can open the vibrating egg's extended dialog
if (DialogCanUseRemoteState(C, CurrentItem) === "Available") { DialogExtendItem(CurrentItem); }
} else {
// Runs the activity arousal process if activated, & publishes the item action text to the chatroom
DialogPublishAction(C, "ActionUse", ClickItem);
ActivityArousalItem(Player, C, ClickItem.Asset);
}
return;
}
/** @type {DialogStruggleActionType} */
let action;
if (CurrentItem && ClickItem) {
action = "ActionSwap";
} else if (ClickItem) {
action = "ActionUse";
} else {
action = "ActionRemove";
}
DialogStruggleStart(C, action, CurrentItem, ClickItem);
}
/**
*
* @param {Character} C
* @param {ItemActivity} clickedActivity
* @param {null | Item} equippedItem
*/
function DialogActivityClick(C, clickedActivity, equippedItem) {
if (C.IsNpc() && clickedActivity.Item) {
let Line = C.FocusGroup.Name + clickedActivity.Item.Asset.DynamicName(Player);
let D = DialogFind(C, Line, null, false);
if (D != "") {
C.CurrentDialog = D;
}
}
IntroductionJobProgress("SubActivity", clickedActivity.Activity.MaxProgress.toString(), true);
if (clickedActivity.Item?.Asset.Name === "ShockRemote") {
if (typeof equippedItem?.Property?.TriggerCount === "number") {
equippedItem.Property.TriggerCount++;
ChatRoomCharacterItemUpdate(C, C.FocusGroup.Name);
}
}
ActivityRun(Player, C, C.FocusGroup, clickedActivity);
// Leave the dialog so we see the character's reaction
if (CurrentScreen === "ChatRoom")
DialogLeave();
}
/**
* Toggle permission of an item in the dialog inventory list
* @param {DialogInventoryItem} item
* @param {boolean} worn - True if the player is changing permissions for an item they're wearing
* @returns {ItemPermissionMode} The new item permission
*/
function DialogInventoryTogglePermission(item, worn) {
const permission = InventoryTogglePermission(item, null, worn);
// Refresh the inventory item
const itemIndex = DialogInventory.findIndex(i => i.Asset.Name == item.Asset.Name && i.Asset.Group.Name == item.Asset.Group.Name);
const sortOrder = /** @type {DialogSortOrder} */ (parseInt(item.SortOrder.replace(/[^0-9].+/gm, '') || DialogSortOrder.Usable)); // only keep the number at the start
DialogInventory[itemIndex] = DialogInventoryCreateItem(Player, item, item.Worn, sortOrder);
return permission;
}
/**
* Changes the dialog mode and perform the initial setup.
*
* @param {DialogMenuMode} mode The new mode for the dialog.
* @param {boolean} reset Whether to reset the mode back to its defaults
*/
function DialogChangeMode(mode, reset=false) {
const C = CharacterGetCurrent();
// Handle changing to the expression color picker having to restore the selected mode & group
if (mode === "colorExpression" && (!DialogExpressionPreviousMode || DialogExpressionPreviousMode.mode !== "colorExpression")) {
DialogExpressionPreviousMode = { mode: DialogMenuMode, group: C.FocusGroup };
}
DialogMenuMapping[DialogMenuMode]?.Unload();
const modeChange = DialogMenuMode !== mode || reset;
DialogMenuMode = mode;
switch (DialogMenuMode) {
case "activities":
case "crafted":
case "items":
case "locked":
case "locking":
case "permissions": {
if (DialogMenuMode !== "locking") {
// FIXME: Ensure we don't leave that set, that's only for "locking" mode
DialogFocusSourceItem = null;
}
if (C && C.FocusGroup) {
DialogMenuMapping[DialogMenuMode].Init({ C, focusGroup: C.FocusGroup });
}
break;
}
case "dialog":
if (C) {
DialogMenuMapping[DialogMenuMode].Init({ C }, null);
}
break;
case "colorItem":
case "colorDefault":
case "colorExpression":
DialogInventoryBuild(C, modeChange);
DialogMenuButtonBuild(C);
DialogBuildActivities(C);
break;
case "extended":
case "tighten":
case "struggle":
DialogMenuButtonBuild(C);
break;
case "layering": {
const item = InventoryGet(C, C?.FocusGroup?.Name);
if (item) {
const mutable = Player.CanInteract() && (!InventoryItemHasEffect(item, "Lock") || DialogCanUnlock(C, item));
Layering.Init(item, C, {
x: DialogInventoryGrid.x,
y: Layering.DisplayDefault.y - 10,
w: (Layering.DisplayDefault.x + Layering.DisplayDefault.w) - DialogInventoryGrid.x,
h: Layering.DisplayDefault.h + 10,
}, reset, !mutable);
}
break;
}
default:
console.warn(`Asked to change to mode ${DialogMenuMode}, but setup missing`);
break;
}
// `DialogChangeMode()` acts as the de facto `Load()` function for dialog subscreens, hence passing along `load: true`
DialogResize(true);
}
/**
* Change the given character's focused group.
* @param {Character} C - The character to change the focus of.
* @param {AssetItemGroup|string} Group - The group that should gain focus.
*/
function DialogChangeFocusToGroup(C, Group) {
/** @type {null | AssetGroup} */
let G = null;
if (typeof Group === "string") {
G = AssetGroupGet(C.AssetFamily, /** @type {AssetGroupName} */ (Group));
if (!Group) return;
} else {
G = Group;
}
// Deselect any extended screen and color picking in progress
// It's done without calling DialogLeaveFocusItem() so it
// acts as cancelling out of a in-progress edit.
DialogLeaveFocusItem(false);
if (DialogMenuMode === "colorExpression" || DialogMenuMode === "colorItem")
ItemColorCancelAndExit();
else if (DialogMenuMode === "colorDefault") {
ColorPickerHide();
ElementRemove("InputColor");
} else if (DialogMenuMode === "layering" && !InventoryGet(C, G?.Name)) {
DialogMenuBack();
}
// Stop sounds & expressions from struggling/swapping items
AudioDialogStop();
// Stop any strugling minigame
if(StruggleMinigameIsRunning()) {
StruggleMinigameStop();
}
// If we're in the two-character dialog, clear their focused group
if (!CurrentCharacter.IsPlayer()) {
Player.FocusGroup = null;
CurrentCharacter.FocusGroup = null;
}
// Now set the selected group and refresh
const previousGroup = C.FocusGroup;
C.FocusGroup = /** @type {AssetItemGroup} */ (G);
if (C.FocusGroup) {
// If we're changing permissions on ourself, don't change to the item list
// Same for activities/layering, keep us in that mode if the focus moves around
if ((DialogMenuMode === "permissions" && C.IsPlayer()) || DialogMenuMode === "activities" || DialogMenuMode === "layering") {
// Set the mode back to itself to trigger a refresh of the state variables.
DialogChangeMode(DialogMenuMode, !previousGroup || C.FocusGroup.Name !== previousGroup.Name);
} else {
DialogChangeMode("items", true);
}
} else {
// We don't have a focused group anymore. Switch to dialog mode.
DialogChangeMode("dialog");
}
}
/**
* Handles the click in the dialog screen
* @type {ScreenFunctions["Click"]}
*/
function DialogClick(event) {
// Check that there's actually a character selected
if (!CurrentCharacter) return;
// Gets the current character
let C = CharacterGetCurrent();
// Check if the user clicked on one of the top menu icons
if (DialogMenuButtonClick()) return;
// User clicked on the interacted character or herself, check if we need to update the menu
if (MouseIn(0, 0, 1000, 1000) && (CurrentCharacter.AllowItem || (MouseX < 500)) && (!CurrentCharacter.IsPlayer() || (MouseX > 500)) && DialogIntro(Player) && DialogAllowItemScreenException()) {
C = (MouseX < 500) ? Player : CurrentCharacter;
let X = MouseX < 500 ? 0 : 500;
for (const Group of AssetGroup) {
if (!Group.IsItem()) continue;
const Zone = Group.Zone.find(Z => DialogClickedInZone(C, Z, 1, X, 0, C.HeightRatio));
if (Zone) {
DialogChangeFocusToGroup(C, Group);
break;
}
}
}
// If the user clicked in the facial expression menu
if (MouseXIn(0, 500) && CurrentCharacter != null) {
DialogSelfMenuClick(CurrentCharacter);
}
// Block out clicking on anything else if we're not supposed to interact with the selected character
if (C.FocusGroup === null || !C.AllowItem) return;
/** The item currently sitting in the focused group */
const FocusItem = InventoryGet(C, C.FocusGroup.Name);
// If the user clicked the Up button, move the character up to the top of the screen
if ((CurrentCharacter.HeightModifier < -90 || CurrentCharacter.HeightModifier > 30) && (CurrentCharacter.FocusGroup != null) && MouseIn(510, 50, 90, 90)) {
CharacterAppearanceForceUpCharacter = CharacterAppearanceForceUpCharacter == CurrentCharacter.MemberNumber ? -1 : CurrentCharacter.MemberNumber;
return;
}
// If the user clicked anywhere outside the current character item zones, ensure the position is corrected
if (CharacterAppearanceForceUpCharacter == CurrentCharacter.MemberNumber && ((MouseX < 500) || (MouseX > 1000) || (CurrentCharacter.FocusGroup == null))) {
CharacterAppearanceForceUpCharacter = -1;
CharacterRefresh(CurrentCharacter, false, false);
}
// If the user wants to speed up the add / swap / remove progress
if (MouseIn(1000, 200, 1000, 800) && DialogMenuMode === "struggle") {
if (StruggleMinigameIsRunning()) {
StruggleMinigameClick();
} else {
for (const [idx, [game, data]] of StruggleGetMinigames().entries()) {
if (MouseIn(1387 + 300 * (idx - 1), 550, 225, 275) && data.DisablingCraftedProperty && !InventoryCraftPropertyIs(DialogStrugglePrevItem, data.DisablingCraftedProperty)) {
StruggleMinigameStart(Player, game, DialogStrugglePrevItem, DialogStruggleNextItem, DialogStruggleStop);
DialogMenuButtonBuild(C);
}
}
if ((CurrentScreen === "ChatRoom") && MouseIn(1300, 880, 400, 65)) StruggleChatRoomStart();
}
return;
}
if (DialogMenuMode === "extended") {
if (DialogFocusItem != null)
CommonDynamicFunction("Inventory" + DialogFocusItem.Asset.Group.Name + DialogFocusItem.Asset.Name + "Click()");
return;
} else if (DialogMenuMode === "tighten") {
if (DialogTightenLoosenItem != null)
TightenLoosenItemClick();
return;
} else if (DialogMenuMode === "colorItem" || DialogMenuMode === "colorExpression") {
if (MouseIn(1300, 25, 675, 950) && FocusItem) {
ItemColorClick(C, C.FocusGroup.Name, 1200, 25, 775, 950, true);
return;
}
}
DialogMenuMapping[DialogMenuMode]?.Click(event);
}
/** @type {ScreenFunctions["Resize"]} */
function DialogResize(load) {
switch (DialogMenuMode) {
case "layering":
Layering.Resize(load);
return;
}
DialogMenuMapping[DialogMenuMode]?.Resize(load);
}
/**
* Returns whether the clicked co-ordinates are inside the asset zone
* @param {Character} C - The character the click is on
* @param {RectTuple} Zone - The 4 part array of the rectangular asset zone on the character's body: [X, Y, Width, Height]
* @param {number} Zoom - The amount the character has been zoomed
* @param {number} X - The X co-ordinate of the click
* @param {number} Y - The Y co-ordinate of the click
* @param {number} HeightRatio - The displayed height ratio of the character
* @returns {boolean} - If TRUE the click is inside the zone
*/
function DialogClickedInZone(C, Zone, Zoom, X, Y, HeightRatio) {
let CZ = DialogGetCharacterZone(C, Zone, X, Y, Zoom, HeightRatio);
return MouseIn(CZ[0], CZ[1], CZ[2], CZ[3]);
}
/**
* Return the co-ordinates and dimensions of the asset group zone as it appears on screen
* @param {Character} C - The character the zone is calculated for
* @param {readonly [number, number, number, number]} Zone - The 4 part array of the rectangular asset zone: [X, Y, Width, Height]
* @param {number} X - The starting X co-ordinate of the character's position
* @param {number} Y - The starting Y co-ordinate of the character's position
* @param {number} Zoom - The amount the character has been zoomed
* @param {number} HeightRatio - The displayed height ratio of the character
* @returns {[number, number, number, number]} - The 4 part array of the displayed rectangular asset zone: [X, Y, Width, Height]
*/
function DialogGetCharacterZone(C, Zone, X, Y, Zoom, HeightRatio) {
X += CharacterAppearanceXOffset(C, HeightRatio) * Zoom;
Y += CharacterAppearanceYOffset(C, HeightRatio) * Zoom;
Zoom *= HeightRatio;
let Left = X + Zone[0] * Zoom;
let Top = CharacterAppearsInverted(C) ? 1000 - (Y + (Zone[1] + Zone[3]) * Zoom) : Y + Zone[1] * Zoom;
let Width = Zone[2] * Zoom;
let Height = Zone[3] * Zoom;
return [Left, Top, Width, Height];
}
/**
* Finds and sets the next available character sub menu.
* @returns {void} - Nothing
*/
function DialogFindNextSubMenu() {
var CurrentIndex = DialogSelfMenuOptions.indexOf(DialogSelfMenuSelected);
if (CurrentIndex == -1) CurrentIndex = 0;
var NextIndex = CurrentIndex + 1 == DialogSelfMenuOptions.length ? 0 : CurrentIndex + 1;
for (let SM = NextIndex; SM < DialogSelfMenuOptions.length; SM++) {
if (DialogSelfMenuOptions[SM].IsAvailable()) {
if (DialogSelfMenuOptions[SM].Load)
DialogSelfMenuOptions[SM].Load();
DialogSelfMenuSelected = DialogSelfMenuOptions[SM];
return;
}
if (SM + 1 == DialogSelfMenuOptions.length) SM = -1;
}
}
/**
* Finds and set an available character sub menu.
* @param {string} MenuName - The name of the sub menu, see DialogSelfMenuOptions.
* @param {boolean} force - Whether to check availability of the menu first.
* @returns {boolean} - True, when the sub menu is found and available and was switched to. False otherwise and nothing happened.
*/
function DialogFindSubMenu(MenuName, force=false) {
for (let MenuIndex = 0; MenuIndex < DialogSelfMenuOptions.length; MenuIndex++) {
let MenuOption = DialogSelfMenuOptions[MenuIndex];
if (MenuOption.Name == MenuName) {
if (force || MenuOption.IsAvailable()) {
if (MenuOption.Load)
MenuOption.Load();
DialogSelfMenuSelected = MenuOption;
return true;
}
return false;
}
}
return false;
}
/**
* Finds and sets a facial expression group. The expression sub menu has to be already opened.
* @param {ExpressionGroupName} ExpressionGroup - The name of the expression group, see XXX.
* @returns {boolean} True, when the expression group was found and opened. False otherwise and nothing happens.
*/
function DialogFindFacialExpressionMenuGroup(ExpressionGroup) {
if (DialogSelfMenuSelected.Name != "Expression") return false;
if (!DialogFacialExpressions || !DialogFacialExpressions.length) DialogFacialExpressionsBuild();
let I = DialogFacialExpressions.findIndex(expr => expr.Group == ExpressionGroup);
if (I != -1) {
DialogFacialExpressionsSelected = I;
if (DialogMenuMode === "colorExpression") ItemColorSaveAndExit();
return true;
}
return false;
}
/**
* Displays the given text for 5 seconds
* @param {string} status - The text to be displayed
* @param {number} timer - the number of milliseconds to display the message for
* @param {null | { asset?: Asset, group?: AssetGroup, C?: Character }} replace - Attempt to perform replacements within the `status` text
* @returns {void} - Nothing
*/
function DialogSetStatus(status, timer=0, replace=null) {
const id = DialogMenuMapping[DialogMenuMode]?.ids.status;
const elem = id ? document.getElementById(id) : null;
if (!elem) {
return;
}
replace ??= {};
if (replace.group || replace.asset) {
status = status.replaceAll("GroupName", DialogActualNameForGroup(replace.C ?? Player, replace.group ?? replace.asset.Group).toLowerCase());
}
if (replace.asset) {
status = status.replaceAll("AssetName", replace.asset.Description);
}
if (replace.C) {
status = status.replaceAll("DialogCharacterObject", CharacterNickname(replace.C));
}
const timeoutID = elem.getAttribute("data-timeout-id");
if (timer > 0) {
if (timeoutID) {
clearTimeout(Number.parseInt(timeoutID, 10));
} else {
elem.setAttribute("data-default", elem.textContent);
}
elem.textContent = status;
elem.setAttribute("data-timeout-id", setTimeout(DialogStatusTimerHandler, timer, elem).toString());
} else {
// We let the timer for the non-default message expire normally
if (timeoutID) {
elem.setAttribute("data-default", status);
} else {
elem.textContent = status;
}
}
}
/**
* Timer handler for managing timed dialog statuses.
* @param {Element} elem - The relevant `span.dialog-status` element
* @satisfies {TimerHandler}
*/
function DialogStatusTimerHandler(elem) {
elem.textContent = elem.getAttribute("data-default") ?? "";
elem.removeAttribute("data-default");
elem.removeAttribute("data-timeout-id");
}
/** Clears the current status message. */
function DialogStatusClear() {
const id = DialogMenuMapping[DialogMenuMode]?.ids.status;
if (!id) {
return;
}
const elem = document.getElementById(id);
const timeoutID = elem?.getAttribute("data-timeout-id");
if (timeoutID) {
clearTimeout(Number.parseInt(timeoutID, 10));
DialogStatusTimerHandler(elem);
}
}
/**
* Shows the extended item menu for a given item, if possible.
* Therefore a dynamic function name is created and then called.
* @param {Item} Item - The item the extended menu should be shown for
* @param {Item} [SourceItem] - The source of the extended menu
* @returns {void} - Nothing
*/
function DialogExtendItem(Item, SourceItem) {
const C = CharacterGetCurrent();
if (AsylumGGTSControlItem(C, Item)) return;
if (InventoryBlockedOrLimited(C, Item)) return;
DialogChangeMode("extended");
DialogFocusItem = Item;
DialogFocusSourceItem = SourceItem;
ExtendedItemInit(C, Item.Asset.IsLock ? SourceItem : Item, false, true);
CommonDynamicFunction("Inventory" + Item.Asset.Group.Name + Item.Asset.Name + "Load()");
}
/**
* Shows the tigthen/loosen item menu for a given item, if possible.
* @param {Item} Item - The item to open the menu for
* @returns {void} - Nothing
*/
function DialogSetTightenLoosenItem(Item) {
const C = CharacterGetCurrent();
if (AsylumGGTSControlItem(C, Item)) return;
if (InventoryBlockedOrLimited(C, Item)) return;
DialogChangeMode("tighten");
DialogTightenLoosenItem = Item;
TightenLoosenItemLoad();
}
/**
* Validates that the player is allowed to change the item color and swaps it on the fly
* @param {Character} C - The player who wants to change the color
* @param {string} Color - The new color in the format "#rrggbb"
* @returns {void} - Nothing
*/
function DialogChangeItemColor(C, Color) {
// Validates that the player isn't blind and can interact with the item
if (!Player.CanInteract() || Player.IsBlind() || !C.FocusGroup) return;
// If the item is locked, make sure the player could unlock it before swapping colors
var Item = InventoryGet(C, C.FocusGroup.Name);
if (Item == null) return;
if (InventoryItemHasEffect(Item, "Lock", true) && !DialogCanUnlock(C, Item)) return;
// Make sure the item is allowed, the group isn't blocked and it's not an enclosing item
if (!InventoryAllow(C, Item.Asset) || InventoryGroupIsBlocked(C)) return;
if (InventoryItemHasEffect(Item, "Enclose", true) && (C.IsPlayer())) return;
// Apply the color & redraw the character after 100ms. Prevent unnecessary redraws to reduce performance impact
Item.Color = Color;
clearTimeout(DialogFocusItemColorizationRedrawTimer);
DialogFocusItemColorizationRedrawTimer = setTimeout(function () { CharacterAppearanceBuildCanvas(C); }, 100);
}
/**
* Draw the list of activities
*
* @deprecated - See {@link DialogMenuMapping.activities.Load} and {@link DialogMenuMapping.activities.Reload} for the new DOM-based menu
* @param {Character} C - The character currently focused in the dialog.
* @returns {void} - Nothing
*/
function DialogDrawActivityMenu(C) {
// Keep around as deprecated no-op
}
/**
* Returns the button image name for a dialog menu button based on the button name.
* @param {DialogMenuButton} ButtonName - The menu button name
* @param {Item} FocusItem - The focused item
* @returns {string} - The button image name
*/
function DialogGetMenuButtonImage(ButtonName, FocusItem) {
if (ButtonName === "ColorDefault" || ButtonName === "ColorChange" || ButtonName === "ColorPickDisabled") {
return ItemColorIsSimple(FocusItem) ? "ColorChange" : "ColorChangeMulti";
} else if (ButtonName.includes("PickLock")) {
return "PickLock";
} else if (DialogIsMenuButtonDisabled(ButtonName)) {
return ButtonName.replace(DialogButtonDisabledTester, "");
} else {
return ButtonName;
}
}
/**
* Returns the background color of a dialog menu button based on the button name.
* @param {DialogMenuButton} ButtonName - The menu button name
* @returns {string} - The background color that the menu button should use
*/
function DialogGetMenuButtonColor(ButtonName) {
if (DialogIsMenuButtonDisabled(ButtonName)) {
return "#808080";
} else if (ButtonName === "ColorDefault") {
return DialogColorSelect || "#fff";
} else {
return "#fff";
}
}
/**
* Determines whether or not a given dialog menu button should be disabled based on the button name.
* @param {DialogMenuButton} ButtonName - The menu button name
* @returns {boolean} - TRUE if the menu button should be disabled, FALSE otherwise
*/
function DialogIsMenuButtonDisabled(ButtonName) {
return DialogButtonDisabledTester.test(ButtonName);
}
/**
* Draw the list of items
*
* @deprecated - See {@link DialogMenuMapping.items.Load} and {@link DialogMenuMapping.items.Reload} for the new DOM-based menu
* @param {Character} C - The character currently focused in the dialog.
* @returns {void} - Nothing
*/
function DialogDrawItemMenu(C) {
// Keep around as deprecated no-op
}
/**
* Abstract base class for a simplistic DOM subscreen with three-ish components:
* - A menubar with a set of buttons which are generally heterogeneous in function (_e.g._ perform arbitrary, unrelated task #1, #2 or #3)
* - A status message of some sort
* - A grid with some type of misc element, generally a set of buttons homogeneous in function (e.g. equip item #1, #2 or #3). See below for more details.
*
* Grid button clicks
* ------------------
* Grid button clicks in the {@link ids|ids.grid}-referenced element generally involve the following four steps:
* 1) The click listener (see {@link eventListeners|eventListeners._ClickButton}) performs some basic generic validation, like checking whether the character has been initialized.
* A validation failure is considered an internal error, and will lead to a premature termination of the click event.
* 2) The click listener retrieves some type of underlying object associated with the grid button, like an item or activity (see {@link _GetClickedObject}).
* 3) The click listener performs more extensive, subscreen-/class-specific validation (see {@link GetClickStatus}), like checking whether an item has not been blacklisted.
* A validation failure here will trigger a soft reload, updating the status message and re-evaluating the enabled/disabled state of _all_ pre-existing grid buttons.
* 4) The click listener finally performs a subscreen-/class-specific action based on the grid button click, like equipping an item (see {@link _ClickButton}).
*
* Parameters
* ----------
* @abstract
* @template {string} [ModeType=string] - The name of the mode associated with this instance (_e.g._ {@link DialogMenuMode})
* @template [ClickedObj=any] - The underlying item or activity object of the clicked grid buttons (if applicable)
* @template {DialogMenu.InitProperties} [PropType=DialogMenu.InitProperties] - Properties as initialized by {@link Init}
* @extends {ScreenFunctions}
*/
class DialogMenu {
/**
* An object containg all DOM element IDs referenced in the {@link DialogMenu} subclass.
* @abstract
* @readonly
* @type {Readonly<Record<string, string> & { root: string, status?: string, grid?: string, paginate?: string, icon?: string, menubar?: string }>}
*/
ids;
/**
* A list of all init property names as supported by this class.
* Represents the set of keys that will be stored in {@link _initProperties}
* @readonly
* @abstract
* @type {readonly (keyof PropType)[]}
*/
_initPropertyNames;
/**
* An object for storing all of this classes init properties.
*
* Subclasses _should_ generally implement a public getter/setter interface for safely manipulating each property stored herein.
* @type {null | PropType}
*/
_initProperties = null;
/**
* An object containg all event listeners referenced in the {@link DialogMenu} subclass.
* @readonly
* @satisfies {Record<string, (this: HTMLElement, ev: Event) => any>}
*/
eventListeners;
/**
* The name of the mode associated with this instance (see {@link DialogMenuMode}).
* @readonly
* @type {ModeType}
*/
mode;
/**
* An object mapping IDs to {@link DialogMenu.GetClickStatus} helper functions.
* Used for evaluating the error statuses of item clicks.
*
* Additional checks can be freely added here.
* @abstract
* @readonly
* @type {Record<string, DialogMenu<string, ClickedObj>["GetClickStatus"]>}
*/
clickStatusCallbacks;
/**
* See {@link DialogMenu.shape}
* @private
* @type {null | RectTuple}
*/
_shape = null;
/**
* Get or set the position & shape of the current subscreen as defined by the root element.
*
* Performs a {@link DialogMenu.Resize} if a new shape is assigned.
*/
get shape() {
return this._shape;
}
set shape(value) {
if (value == null) {
this._shape = null;
} else if (value != null && !CommonArraysEqual(this._shape, value)) {
this._shape = value;
this.Resize(false);
}
}
/**
* The default position & shape of the current subscreen as defined by the root element.
*
* See {@link DialogMenu.shape}.
* @readonly
* @type {Readonly<RectTuple>}
*/
defaultShape = Object.freeze([1005, 107, 995, 857]);
/**
* Get or set the currently selected character.
*
* Performs a hard {@link DialogMenu.Reload} if a new character is assigned.
*/
get C() {
return this._initProperties?.C ?? null;
}
set C(value) {
if (this._initProperties == null) {
return;
}
const elem = document.getElementById(this.ids.root);
if (value == null) {
this._initProperties.C = null;
elem?.removeAttribute("data-character-id");
} else if (this.C.ID !== value.ID) {
this._initProperties.C = value;
elem?.setAttribute("data-character-id", value.ID);
this.Reload(null, { reset: true });
}
}
/**
* Get or set the currently selected group.
*
* Performs a hard {@link DialogMenu.Reload} if a new focus group is assigned.
* @type {null | AssetItemGroup}
*/
get focusGroup() { return null; }
/**
* A set with the numeric IDs of to-be run reloads.
* See {@link DialogMenu.Reload}
* @private
* @readonly
* @type {Set<number>}
*/
_reloadQueue;
/**
* The highest reload ID currently in use.
* See {@link DialogMenu.Reload}
* @private
* @type {number}
*/
_reloadHighestID = -1;
/**
* Promise object for queuing reloads, ensuring that they are run consecutively rather than concurrently if multiple calls are invoked (near) simultenously.
* See {@link DialogMenu.Reload}
* @private
* @type {Promise<boolean>}
*/
_reloadPromise;
/**
* @param {ModeType} mode The name of the mode associated with this instance
*/
constructor(mode) {
this.mode = mode;
this._reloadQueue = new Set;
this._reloadPromise = Promise.resolve(true);
const dialogMenu = this;
this.eventListeners = {
/**
* @type {(this: HTMLButtonElement, ev: MouseEvent) => null | string}
* @returns A status message if an _unexpected_ error is encountered and null otherwise
*/
_ClickButton: function(event) {
const clickedObj = dialogMenu._GetClickedObject(this);
if (!clickedObj || !dialogMenu.C) {
event.stopImmediatePropagation();
return "Internal error";
}
const equippedItem = dialogMenu.focusGroup ? InventoryGet(dialogMenu.C, dialogMenu.focusGroup.Name) : null;
const status = dialogMenu.GetClickStatus(dialogMenu.C, clickedObj, equippedItem);
if (status) {
event.stopImmediatePropagation();
dialogMenu.Reload(null, { status, statusTimer: DialogTextDefaultDuration });
return status;
} else {
dialogMenu._ClickButton(this, dialogMenu.C, clickedObj, equippedItem);
return null;
}
},
/**
* @type {(this: HTMLButtonElement, ev: MouseEvent) => null | string}
* @returns A status message if an _expected_ error is encountered and null otherwise
*/
_ClickDisabledButton: function(event) {
const clickedObj = dialogMenu._GetClickedObject(this);
if (!clickedObj || !dialogMenu.C) {
event.stopImmediatePropagation();
return null;
}
const equippedItem = dialogMenu.focusGroup ? InventoryGet(dialogMenu.C, dialogMenu.focusGroup.Name) : null;
const status = dialogMenu.GetClickStatus(dialogMenu.C, clickedObj, equippedItem);
if (status) {
DialogSetStatus(status, DialogTextDefaultDuration, { C: dialogMenu.C });
return status;
} else {
// Force a reload (and a new click event) if the click somehow _did not_ fail
dialogMenu.Reload().then((reloadStatus) => {
if (reloadStatus && this.getAttribute("aria-disabled") !== "true") {
this.dispatchEvent(new MouseEvent("click", event));
}
});
event.stopImmediatePropagation();
return null;
}
},
/**
* See {@link DialogMenu.KeyDown}
* @type {(this: HTMLButtonElement, ev: MouseEvent) => void}
*/
_ClickPaginatePrev: function(event) {
document.getElementById("MainCanvas")?.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp" }));
},
/**
* See {@link DialogMenu.KeyDown}
* @type {(this: HTMLButtonElement, ev: MouseEvent) => void}
*/
_ClickPaginateNext: function(event) {
document.getElementById("MainCanvas")?.dispatchEvent(new KeyboardEvent("keydown", { key: "PageDown" }));
},
/**
* Provide more consistent mouse wheel scroll behavior by using browser-independent increments.
* @type {(this: HTMLDivElement, event: WheelEvent) => void}
*/
_WheelGrid: function(event) {
event.preventDefault();
if (event.deltaY === 0) {
event.stopImmediatePropagation();
return;
}
const increment = this.querySelector(".dialog-button")?.clientHeight ?? this.clientHeight / 3;
this.scrollBy({
top: Math.sign(event.deltaY) * increment,
behavior: "instant",
});
},
};
}
/**
* Initialize the {@link DialogMenu} subscreen.
*
* Serves as a {@link ScreenFunctions["Load"]} wrapper with added parameters.
* @param {PropType} properties The to be initialized character and any other properties
* @param {null | { shape?: RectTuple }} style Misc styling for the subscreen
* @returns {null | HTMLDivElement} The div containing the dialog subscreen root element or `null` if the screen failed to initialize
*/
Init(properties, style=null) {
style ??= {};
this._initProperties = CommonPick(properties, this._initPropertyNames);
this._shape = style.shape ?? [...this.defaultShape];
this.Load();
return /** @type {null | HTMLDivElement} */(document.getElementById(this.ids.root));
}
/** @type {ScreenFunctions["Load"]} */
Load() {
if (this._initPropertyNames.some(p => this._initProperties?.[p] == null)) {
console.error(
"Aborting, one or more uninitialized properties",
CommonPick(/** @type {Partial<PropType>} */(this._initProperties ?? {}), this._initPropertyNames),
);
this.Exit();
return;
}
const root = this._Load();
root.setAttribute("id", this.ids.root);
root.setAttribute("screen-generated", CurrentScreen);
root.setAttribute("data-character-id", this.C.ID);
root.setAttribute("data-mode", this.mode);
root.classList.add("HideOnPopup", "dialog-root");
if (!root.parentElement) { document.body.append(root); }
root.toggleAttribute("data-unload", false);
DialogMenuButtonBuild(this.C);
// Perform the element resizing here asynchronically in order to circumvent a race condition with the scroll bar reallignment
const shape = this.shape;
const buttonGrid = this.ids.grid ? document.getElementById(this.ids.grid) : null;
this.Reload(null, { reset: true, resetScrollbar: false }).then((status) => {
if (status) {
ElementPositionFixed(root, ...shape);
const checkedButton = buttonGrid?.querySelector(`.dialog-grid-button[aria-checked='true']`);
if (checkedButton) {
checkedButton.scrollIntoView({ behavior: "instant" });
} else {
buttonGrid?.scrollTo({ top: 0, behavior: "instant" });
}
} else {
this.Exit();
}
});
}
/**
* Construct and return the (unpopulated) {@link DialogMenu.ids.root} element.
* @abstract
* @returns {HTMLElement}
*/
_Load() {
throw new Error("Trying to call an abstract method");
}
/** @type {ScreenFunctions["Unload"]} */
Unload() {
DialogStatusClear();
document.getElementById(this.ids.root)?.toggleAttribute("data-unload", true);
this._reloadQueue.clear();
}
/** @type {ScreenFunctions["Click"]} */
Click(event) {}
/** @type {ScreenFunctions["Draw"]} */
Draw() {}
/** @type {ScreenFunctions["Run"]} */
Run(time) {}
/** @type {ScreenFunctions["Resize"]} */
Resize(load) {
if (!load) {
// Tasks already handled asynchronically by `Load`
ElementPositionFixed(this.ids.root, ...this.shape);
}
}
/** @type {ScreenFunctions["Exit"]} */
Exit() {
ElementRemove(this.ids.root);
this._initProperties = null;
this._shape = null;
this._reloadQueue.clear();
}
/** @type {ScreenFunctions["KeyDown"]} */
KeyDown(event) {
const grid = this.ids.grid ? document.getElementById(this.ids.grid) : null;
if (grid) {
return CommonKey.NavigationKeyDown(
grid, event,
(el) => el.querySelector(".dialog-button")?.clientHeight ?? el.clientHeight / 3,
);
} else {
return false;
}
}
/**
* Reload the subscreen, updating the DOM elements and, if required, re-assigning the character and focus group.
* @param {null | Partial<PropType>} properties
* @param {null | DialogMenu.ReloadOptions} [options] - Further customization options
* @returns {Promise<boolean>} - Whether an update was triggered or aborted
*/
Reload(properties=null, options=null) {
const id = this._reloadHighestID++;
this._reloadQueue.add(id);
this._reloadPromise = this._reloadPromise.then(async () => {
if (!this._reloadQueue.has(id)) {
return false;
} else {
this._reloadQueue.delete(id);
}
const { status, param } = this._ReloadValidate(properties ?? {});
if (status) {
param.root.setAttribute("aria-busy", "true");
await param.textCache.loadedPromise;
const finalStatus = this._Reload(param, options ?? {});
param.root.removeAttribute("aria-busy");
return finalStatus;
} else {
return false;
}
});
return this._reloadPromise;
}
/**
* See {@link DialogMenu.Reload}
* @param {Partial<PropType>} properties
* @returns {{ status: false, param?: never } | { status: true, param: DialogMenu.ReloadParam<PropType> }}
*/
_ReloadValidate(properties) {
const currentProp = CommonPick(/** @type {Partial<PropType>} */(this._initProperties ?? {}), this._initPropertyNames);
const newProp = CommonPick(properties, this._initPropertyNames);
for (const k of Object.keys(newProp)) {
newProp[k] ??= currentProp[k];
}
const root = document.getElementById(this.ids.root);
const textCache = TextAllScreenCache.get(InterfaceStringsPath);
if (!root || Object.values(newProp).some(i => i == null) || !textCache) {
const err = Object.fromEntries(Object.entries(newProp).filter(([_, i]) => i == null));
console.error(
`Failed to reload ${this.mode} subscreen; one or more missing objects`,
{ root: !!root, ...err, textCache: !!textCache },
);
return { status: false };
} else {
return { status: true, param: { root, textCache, newProperties: newProp, oldProperties: currentProp } };
}
}
/**
* See {@link DialogMenu.Reload}
* @param {DialogMenu.ReloadParam} param
* @param {DialogMenu.ReloadOptions} options
* @returns {boolean}
*/
_Reload(param, options) {
const { root, newProperties, oldProperties } = param;
// Check if any class-level properties have to be updated
for (const key of this._initPropertyNames) {
if (newProperties[key] !== oldProperties[key]) {
options.reset ??= true;
this._initProperties[key] = newProperties[key];
}
}
options.resetScrollbar ??= options.reset;
options.resetDialogItems ??= options.reset;
// Update the label, icon and grid (if applicable)
const statusSpan = this.ids.status ? document.getElementById(this.ids.status) : null;
if (statusSpan) {
this._ReloadStatus(root, statusSpan, newProperties, options);
}
const buttonGrid = this.ids.grid ? document.getElementById(this.ids.grid) : null;
if (buttonGrid) {
this._ReloadButtonGrid(root, buttonGrid, newProperties, options);
}
const icon = this.ids.icon ? document.getElementById(this.ids.icon) : null;
if (icon) {
this._ReloadIcon(root, icon, newProperties, options);
}
const menubar = this.ids.menubar ? document.getElementById(this.ids.menubar) : null;
if (menubar) {
this._ReloadMenubar(root, menubar, newProperties, options);
}
return true;
}
/**
* A {@link DialogMenu.Reload} helper function for reloading {@link DialogMenu.ids.status} elements.
* @abstract
* @param {HTMLElement} root
* @param {HTMLElement} status
* @param {PropType} properties
* @param {Pick<DialogMenu.ReloadOptions, "status" | "statusTimer">} options
*/
_ReloadStatus(root, status, properties, options) {
throw new Error("Trying to call an abstract method");
}
/**
* A {@link DialogMenu.Reload} helper function for reloading {@link DialogMenu.ids.grid} elements.
* @abstract
* @param {HTMLElement} root
* @param {HTMLElement} buttonGrid
* @param {PropType} properties
* @param {Pick<DialogMenu.ReloadOptions, "reset" | "resetScrollbar" | "resetDialogItems">} options
*/
_ReloadButtonGrid(root, buttonGrid, properties, options) {
throw new Error("Trying to call an abstract method");
}
/**
* A {@link DialogMenu.Reload} helper function for reloading {@link DialogMenu.ids.icon} elements.
* @abstract
* @param {HTMLElement} root
* @param {HTMLElement} icon
* @param {PropType} properties
* @param {Pick<DialogMenu.ReloadOptions, never>} options
*/
_ReloadIcon(root, icon, properties, options) {
throw new Error("Trying to call an abstract method");
}
/**
* A {@link DialogMenu.Reload} helper function for reloading {@link DialogMenu.ids.menubar} elements.
* @abstract
* @param {HTMLElement} root
* @param {HTMLElement} menubar
* @param {PropType} properties
* @param {Pick<DialogMenu.ReloadOptions, "reset">} options
*/
_ReloadMenubar(root, menubar, properties, options) {
throw new Error("Trying to call an abstract method");
}
/**
* Return an error status (if any) for when an item or activity is clicked.
*
* Error statuses are used for evaluating whether the relevant grid buttons must be disabled or not.
* @param {Character} C - The target character
* @param {ClickedObj} clickedObj - The item that is clicked
* @param {null | Item} equippedItem - The item that is equipped (if any)
* @returns {null | string} - The error status or `null` if everything is ok
*/
GetClickStatus(C, clickedObj, equippedItem=null) {
for (const statusCallback of Object.values(this.clickStatusCallbacks)) {
const status = statusCallback(C, clickedObj, equippedItem);
if (status != null) {
return status;
}
}
return null;
}
/**
* Return the underlying item or activity object of the passed grid button.
* @abstract
* @param {HTMLButtonElement} button - The clicked button
* @returns {ClickedObj | undefined} - The button's underlying item or activity object
*/
_GetClickedObject(button) {
throw new Error("Trying to all an abstract method");
}
/**
* Helper function for handling the clicks of succesfully validated grid button clicks.
* @abstract
* @param {HTMLButtonElement} button - The clicked button
* @param {Character} C - The target character
* @param {ClickedObj} clickedObj - The buttons underlying object (item or activity)
* @param {null | Item} equippedItem - The currently equipped item
* @returns {void}
*/
_ClickButton(button, C, clickedObj, equippedItem) {
throw new Error("Trying to all an abstract method");
}
/**
* @param {string} id
* @returns {HTMLDivElement}
*/
_ConstructPaginateButtons(id) {
return ElementMenu.Create(
id,
[
ElementButton.Create(
`${id}-prev`,
this.eventListeners._ClickPaginatePrev,
{ image: "./Icons/Up.png", tooltip: InterfaceTextGet("PrevPage"), tooltipPosition: "left" },
{ button: {
classList: ["dialog-paginate-button"],
attributes: {
"aria-keyshortcuts": "PageUp",
"aria-controls": this.ids.grid,
},
}},
),
ElementButton.Create(
`${id}-next`,
this.eventListeners._ClickPaginateNext,
{ image: "./Icons/Down.png", tooltip: InterfaceTextGet("NextPage"), tooltipPosition: "left" },
{ button: {
classList: ["dialog-paginate-button"],
attributes: {
"aria-keyshortcuts": "PageDown",
"aria-controls": this.ids.grid,
},
}},
),
],
{},
{ "menu": {
classList: ["dialog-paginate"],
attributes: { "aria-orientation": "vertical" },
parent: ElementNoParent,
}},
);
}
}
/**
* {@link DialogMenu} abstract subclass for dialog menus with a focus group.
* @abstract
* @template {string} [ModeType=string] - The name of the mode associated with this instance (_e.g._ {@link DialogMenuMode})
* @template {DialogInventoryItem | ItemActivity | DialogLine | null} [ClickedObj=any] - The underlying item or activity object of the clicked grid buttons (if applicable)
* @template {{ C: Character, focusGroup: AssetItemGroup }} [PropType={ C: Character, focusGroup: AssetItemGroup }]
* @extends {DialogMenu<ModeType, ClickedObj, PropType>}
*/
class _DialogFocusMenu extends DialogMenu {
/**
* Get or set the currently selected group.
*
* Performs a hard {@link DialogMenu.Reload} if a new focus group is assigned.
*/
get focusGroup() {
return this._initProperties?.focusGroup ?? null;
}
set focusGroup(value) {
if (this._initProperties == null) {
return;
}
const elem = document.getElementById(this.ids.root);
if (value == null) {
this._initProperties.focusGroup = null;
elem?.removeAttribute("data-group");
} else if (this.focusGroup.Name !== value.Name) {
this._initProperties.focusGroup = value;
elem?.setAttribute("data-group", value.Name);
this.Reload(null, { reset: true });
}
}
/**
* @param {ModeType} mode The name of the mode associated with this instance
*/
constructor(mode) {
super(mode);
const dialogMenu = this;
this.eventListeners = {
...this.eventListeners,
/**
* @type {(this: HTMLButtonElement, ev: MouseEvent) => null | string}
* @returns A status message if an _unexpected_ error is encountered and null otherwise
*/
_ClickButton: function(event) {
const clickedObj = dialogMenu._GetClickedObject(this);
if (!clickedObj || !dialogMenu.C || !dialogMenu.focusGroup) {
event.stopImmediatePropagation();
return "Internal error";
}
const equippedItem = InventoryGet(dialogMenu.C, dialogMenu.focusGroup.Name);
const status = dialogMenu.GetClickStatus(dialogMenu.C, clickedObj, equippedItem);
if (status) {
event.stopImmediatePropagation();
dialogMenu.Reload(null, { status, statusTimer: DialogTextDefaultDuration });
return status;
} else {
dialogMenu._ClickButton(this, dialogMenu.C, clickedObj, equippedItem);
return null;
}
},
/**
* @type {(this: HTMLButtonElement, ev: MouseEvent) => null | string}
* @returns A status message if an _expected_ error is encountered and null otherwise
*/
_ClickDisabledButton: function(event) {
const clickedObj = dialogMenu._GetClickedObject(this);
if (!clickedObj || !dialogMenu.C || !dialogMenu.focusGroup) {
event.stopImmediatePropagation();
return null;
}
const equippedItem = InventoryGet(dialogMenu.C, dialogMenu.focusGroup.Name);
const status = dialogMenu.GetClickStatus(dialogMenu.C, clickedObj, equippedItem);
if (status) {
DialogSetStatus(status, DialogTextDefaultDuration, { asset: equippedItem?.Asset, C: dialogMenu.C, group: dialogMenu.focusGroup });
return status;
} else {
// Force a reload (and a new click event) if the click somehow _did not_ fail
dialogMenu.Reload().then((reloadStatus) => {
if (reloadStatus && this.getAttribute("aria-disabled") !== "true") {
this.dispatchEvent(new MouseEvent("click", event));
}
});
event.stopImmediatePropagation();
return null;
}
},
};
}
/** @type {DialogMenu["Load"]} */
Load() {
super.Load();
document.getElementById(this.ids.root)?.setAttribute("data-group", this.focusGroup.Name);
}
}
/**
* @template {string} T
* @extends {_DialogFocusMenu<T, DialogInventoryItem>}
*/
class _DialogItemMenu extends _DialogFocusMenu {
ids = Object.freeze({
root: "dialog-inventory",
status: "dialog-inventory-status",
grid: "dialog-inventory-grid",
icon: "dialog-inventory-icon",
paginate: "dialog-inventory-paginate",
});
_initPropertyNames = /** @type {const} */(["C", "focusGroup"]);
/** @satisfies {DialogMenu<T, DialogInventoryItem>["clickStatusCallbacks"]} */
clickStatusCallbacks = {
InventoryBlockedOrLimited: (C, clickedItem, equippedItem) => {
return InventoryBlockedOrLimited(C, clickedItem) ? InterfaceTextGet("ExtendedItemNoItemPermission") : null;
},
InventoryDisallow: (C, clickedItem, equippedItem) => {
return InventoryDisallow(C, clickedItem.Asset);
},
CanInteract: (C, clickedItem, equippedItem) => {
return Player.CanInteract() ? null : InterfaceTextGet("AccessBlocked");
},
InventoryGroupIsAvailable: (C, clickedItem, equippedItem) => {
return equippedItem ? InventoryGroupIsAvailable(C, /** @type {AssetGroupItemName} */(clickedItem.Asset.Group.Name), false) : null;
},
AsylumGGTSControlItem: (C, clickedItem, equippedItem) => {
return equippedItem && AsylumGGTSControlItem(C, equippedItem) ? InterfaceTextGet("BlockedByGGTS") : null;
},
DialogAllowItemClick: (C, clickedItem, equippedItem) => {
// Allow extended items to pass through; clicking them opens their extended item mneu
return (
equippedItem
&& !DialogAllowItemClick(equippedItem, clickedItem)
&& !equippedItem.Asset.Extended
) ? InterfaceTextGet("BlockedByEquipped") : null;
},
DialogCanUnlock: (C, clickedItem, equippedItem) => {
if (!equippedItem) {
return null;
} else if (!DialogAllowItemClick(equippedItem, clickedItem) && equippedItem.Asset.Extended) {
// Always allow access for equipped extended items; the extended item menu is responsible for enabling/disabling its own buttons
return null;
} else if (InventoryItemHasEffect(equippedItem, "Lock", true) && !DialogCanUnlock(C, equippedItem)) {
return InterfaceTextGet("BlockedByLock");
} else {
return null;
}
},
InventoryChatRoomAllow: (C, clickedItem, equippedItem) => {
return InventoryChatRoomAllow(clickedItem.Asset.Category) ? null : InterfaceTextGet("BlockedByRoom");
},
SelfBondage: (C, clickedItem, equippedItem) => {
if (
clickedItem.Asset.SelfBondage <= 0
|| SkillGetLevel(Player, "SelfBondage") >= clickedItem.Asset.SelfBondage
|| !C.IsPlayer()
|| DialogAlwaysAllowRestraint()
) {
return null;
} else if (clickedItem.Asset.SelfBondage <= 10) {
return InterfaceTextGet(`RequireSelfBondage${clickedItem.Asset.SelfBondage}`);
} else {
return InterfaceTextGet("CannotUseOnSelf");
}
},
};
_Load() {
DialogBuildActivities(this.C, false);
const ids = this.ids;
return document.getElementById(ids.root) ?? ElementCreate({
tag: "div",
children: [
{
tag: "span",
attributes: { id: ids.status, "aria-hidden": "true" },
classList: ["dialog-status", "scroll-box"],
},
this._ConstructPaginateButtons(this.ids.paginate),
{
tag: "div",
attributes: { id: ids.grid, "aria-labelledby": ids.status },
classList: ["dialog-grid", "scroll-box"],
// FIXME: Figure out how the let the listeners distinguish between mouse wheels and touch pads,
// as the latter really needs smooth scrolling behavior while the former needs instant
//
// eventListeners: { wheel: this.eventListeners._WheelGrid },
},
{
tag: "div",
classList: ["button", "blank-button", "button-styling", "dialog-grid-button", "dialog-icon"],
attributes: { id: ids.icon, "aria-labelledby": ids.status },
},
],
});
}
/** @type {_DialogFocusMenu["_ReloadStatus"]} */
_ReloadStatus(root, span, properties, options) {
const { C, focusGroup } = properties;
/** @type {null | Asset} */
let asset = null;
let showIcon = false;
let textContent = options.status;
if (textContent == null) {
switch (this.mode) {
case "locked": {
const item = InventoryGet(C, focusGroup.Name);
const lock = InventoryGetLock(item);
if (!lock || !DialogCanInspectLock(item)){
textContent = InterfaceTextGet("SelectLockedUnknown");
} else {
asset = lock.Asset;
textContent = InterfaceTextGet("SelectLocked").replace("AssetName", lock.Asset.DynamicDescription(C).toLowerCase());
}
showIcon = true;
break;
}
case "items":
if (InventoryGroupIsBlockedByOwnerRule(C, focusGroup.Name)) {
textContent = InterfaceTextGet("ZoneBlockedOwner");
showIcon = true;
} else if (InventoryIsBlockedByDistance(C)) {
textContent = InterfaceTextGet("ZoneBlockedRange");
showIcon = true;
} else if (InventoryGroupIsBlocked(C, focusGroup.Name)) {
textContent = InterfaceTextGet("ZoneBlocked");
showIcon = true;
} else if (!Player.CanInteract()) {
textContent = InterfaceTextGet("AccessBlocked");
showIcon = true;
} else {
textContent = InterfaceTextGet("SelectItemGroup");
}
break;
}
}
root.toggleAttribute("data-show-icon", showIcon);
DialogSetStatus(textContent, options.statusTimer ?? 0, { asset, group: focusGroup, C });
}
/** @type {_DialogFocusMenu["_ReloadButtonGrid"]} */
_ReloadButtonGrid(root, buttonGrid, properties, options) {
const { C, focusGroup } = properties;
if (options.resetDialogItems) {
DialogInventoryBuild(C, false, false, false);
}
if (options.reset) {
buttonGrid.innerHTML = "";
DialogInventory.forEach((clickedItem, i) => ElementButton.CreateForAsset(
`dialog-inventory-${i}`,
clickedItem,
C,
this.eventListeners._ClickButton,
{ clickDisabled: this.eventListeners._ClickDisabledButton },
{ button: {
parent: buttonGrid,
classList: ["dialog-grid-button"],
attributes: { "aria-checked": clickedItem.Worn.toString(), "screen-generated": undefined },
dataAttributes: { index: i },
}},
));
}
const equippedItem = InventoryGet(C, focusGroup.Name);
for (const [i, button] of Array.from(buttonGrid.children).entries()) {
const clickedItem = DialogInventory[i];
// Tasks already performed during a full `options.resetDialogItems` operation
if (!options.resetDialogItems) {
const wasWorn = clickedItem.Worn;
const isWorn = !!(equippedItem && !DialogAllowItemClick(equippedItem, clickedItem));
if (isWorn) {
Object.assign(clickedItem, equippedItem);
button.toggleAttribute("data-unload", false);
}
if (isWorn !== wasWorn) {
clickedItem.Worn = isWorn;
button.setAttribute("aria-checked", isWorn.toString());
}
}
const status = this.GetClickStatus(C, clickedItem, equippedItem);
if (status) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
if (options.reset) {
continue;
}
// Tasks already performed during a full `options.reset` operation
button.toggleAttribute("data-hidden", CharacterAppearanceItemIsHidden(clickedItem.Asset.Name, clickedItem.Asset.Group.Name));
button.toggleAttribute("data-vibrating", clickedItem.Property?.Effect?.includes("Vibrating") ?? false);
ElementButton.ReloadAssetIcons(/** @type {HTMLButtonElement} */(button), clickedItem, C);
}
if (options.resetScrollbar) {
const checkedButton = buttonGrid.querySelector(`.dialog-grid-button[aria-checked='true']`);
if (checkedButton) {
checkedButton.scrollIntoView({ behavior: "instant" });
} else {
buttonGrid.scrollTo({ top: 0, behavior: "instant" });
}
}
}
/** @type {_DialogFocusMenu["_ReloadIcon"]} */
_ReloadIcon(root, icon, properties, options) {
const grid = document.getElementById(this.ids.grid);
const dataAttr = ["data-craft", "data-hidden", "data-vibrating"];
const checkedButton = grid.querySelector(".dialog-grid-button[aria-checked='true']");
if (checkedButton) {
icon.innerHTML = checkedButton.innerHTML;
dataAttr.forEach(attr => icon.toggleAttribute(attr, checkedButton.hasAttribute(attr)));
} else {
icon.innerHTML = "";
dataAttr.forEach(attr => icon.toggleAttribute(attr, false));
}
}
/** @type {_DialogFocusMenu["_ReloadMenubar"]} */
_ReloadMenubar(root, menubar, properties, options) { /** noop */ }
/**
* For a given grid button return the underlying item or activity.
* @type {DialogMenu<string, DialogInventoryItem>["_GetClickedObject"]}
*/
_GetClickedObject(button) {
return DialogInventory[Number.parseInt(button.getAttribute("data-index"), 10)];
}
/**
* @type {DialogMenu<string, DialogInventoryItem>["_ClickButton"]}
*/
_ClickButton(button, C, clickedObj, equippedItem) {
DialogItemClick(clickedObj, C, equippedItem);
if (clickedObj.Asset.Wear && button.getAttribute("aria-checked") !== "true") {
button.closest(".dialog-grid")?.querySelector("[aria-checked='true']")?.setAttribute("aria-checked", "false");
button.setAttribute("aria-checked", "true");
}
}
}
/**
* @template {string} T
* @extends {_DialogFocusMenu<T, DialogInventoryItem>}
*/
class _DialogLockingMenu extends _DialogFocusMenu {
ids = Object.freeze({
root: "dialog-locking",
status: "dialog-locking-status",
grid: "dialog-locking-grid",
paginate: "dialog-locking-paginate",
});
_initPropertyNames = /** @type {const} */(["C", "focusGroup"]);
/** @satisfies {DialogMenu<T, DialogInventoryItem>["clickStatusCallbacks"]} */
clickStatusCallbacks = {
InventoryBlockedOrLimited: (C, clickedLock, equippedItem) => {
return InventoryBlockedOrLimited(C, clickedLock) ? InterfaceTextGet("ExtendedItemNoItemPermission") : null;
},
CurrentItem: (C, clickedLock, equippedItem) => {
return equippedItem ? null : InterfaceTextGet("NoItemEquipped");
},
InventoryDoesItemAllowLock: (C, clickedLock, equippedItem) => {
return InventoryDoesItemAllowLock(equippedItem) ? null : InterfaceTextGet("AccessBlocked");
},
};
_Load() {
const ids = this.ids;
return document.getElementById(ids.root) ?? ElementCreate({
tag: "div",
children: [
{
tag: "span",
attributes: { id: ids.status, "aria-hidden": "true" },
classList: ["dialog-status", "scroll-box"],
},
this._ConstructPaginateButtons(this.ids.paginate),
{
tag: "div",
attributes: { id: ids.grid, "aria-labelledby": ids.status },
classList: ["dialog-grid", "scroll-box"],
// FIXME: Figure out how the let the listeners distinguish between mouse wheels and touch pads,
// as the latter really needs smooth scrolling behavior while the former needs instant
//
// eventListeners: { wheel: this.eventListeners._WheelGrid },
},
],
});
}
/** @type {_DialogFocusMenu["_ReloadStatus"]} */
_ReloadStatus(root, status, properties, options) {
const { C, focusGroup } = properties;
const textContent = options.status ?? InterfaceTextGet("SelectLock");
DialogSetStatus(textContent, options.statusTimer ?? 0, { group: focusGroup, C });
}
/** @type {_DialogFocusMenu["_ReloadButtonGrid"]} */
_ReloadButtonGrid(root, buttonGrid, properties, options) {
const { C, focusGroup } = properties;
if (options.resetDialogItems) {
DialogInventoryBuild(C, false, true, false);
}
if (options.reset) {
buttonGrid.innerHTML = "";
DialogInventory.forEach((clickedItem, i) => ElementButton.CreateForAsset(
`dialog-locking-${i}`,
clickedItem,
C,
this.eventListeners._ClickButton,
{ clickDisabled: this.eventListeners._ClickDisabledButton },
{ button: {
parent: buttonGrid,
classList: ["dialog-grid-button"],
attributes: { "screen-generated": undefined },
dataAttributes: { index: i },
}},
));
}
const equippedItem = InventoryGet(C, focusGroup.Name);
for (const [i, button] of Array.from(buttonGrid.children).entries()) {
const clickedLock = DialogInventory[i];
if (this.GetClickStatus(C, clickedLock, equippedItem)) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
if (!options.reset) {
// Tasks already performed during a full `options.reset`
button.toggleAttribute("data-hidden", CharacterAppearanceItemIsHidden(clickedLock.Asset.Name, clickedLock.Asset.Group.Name));
ElementButton.ReloadAssetIcons(/** @type {HTMLButtonElement} */(button), clickedLock, C);
}
}
if (options.resetScrollbar) {
buttonGrid.scrollTo({ top: 0, behavior: "instant" });
}
}
/** @type {_DialogFocusMenu["_ReloadIcon"]} */
_ReloadIcon(root, icon, properties, options) { /** noop */ }
/** @type {_DialogFocusMenu["_ReloadMenubar"]} */
_ReloadMenubar(root, menubar, properties, options) { /** noop */ }
/** @type {DialogMenu<string, DialogInventoryItem>["_GetClickedObject"]} */
_GetClickedObject(button) {
return DialogInventory[Number.parseInt(button.getAttribute("data-index"), 10)];
}
/** @type {DialogMenu<string, DialogInventoryItem>["_ClickButton"]} */
_ClickButton(button, C, clickedLock, equippedItem) {
DialogLockingClick(clickedLock, C, equippedItem);
}
}
/**
* @template {string} T
* @extends {_DialogFocusMenu<T, DialogInventoryItem>}
*/
class _DialogPermissionMenu extends _DialogFocusMenu {
ids = Object.freeze({
root: "dialog-permission",
status: "dialog-permission-status",
grid: "dialog-permission-grid",
paginate: "dialog-permission-paginate",
});
_initPropertyNames = /** @type {const} */(["C", "focusGroup"]);
/** @type {DialogMenu<T, DialogInventoryItem>["clickStatusCallbacks"]} */
clickStatusCallbacks = {
IsPlayer: (C, clickedItem, equippedItem) => {
return C.IsPlayer() ? null : InterfaceTextGet("RequirePlayer");
},
};
_Load() {
const ids = this.ids;
return document.getElementById(ids.root) ?? ElementCreate({
tag: "div",
children: [
{
tag: "span",
attributes: { id: ids.status, "aria-hidden": "true" },
classList: ["dialog-status", "scroll-box"],
},
this._ConstructPaginateButtons(this.ids.paginate),
{
tag: "div",
attributes: { id: ids.grid, "aria-labelledby": ids.status },
classList: ["dialog-grid", "scroll-box"],
// FIXME: Figure out how the let the listeners distinguish between mouse wheels and touch pads,
// as the latter really needs smooth scrolling behavior while the former needs instant
//
// eventListeners: { wheel: this.eventListeners._WheelGrid },
},
],
});
}
/** @type {_DialogFocusMenu["_ReloadStatus"]} */
_ReloadStatus(root, span, properties, options) {
const textContent = options.status ?? InterfaceTextGet("DialogMenuPermissionMode");
DialogSetStatus(textContent, options.statusTimer ?? 0);
}
/** @type {_DialogFocusMenu["_ReloadButtonGrid"]} */
_ReloadButtonGrid(root, buttonGrid, properties, options) {
const { C, focusGroup } = properties;
if (options.resetDialogItems) {
DialogInventoryBuild(C, false, false, false);
}
if (options.reset) {
buttonGrid.innerHTML = "";
DialogInventory.forEach((clickedItem, i) => ElementButton.CreateForAsset(
`dialog-permission-${i}`,
clickedItem,
C,
this.eventListeners._ClickButton,
{ clickDisabled: this.eventListeners._ClickDisabledButton },
{ button: {
parent: buttonGrid,
classList: ["dialog-grid-button"],
attributes: { "screen-generated": undefined },
dataAttributes: { index: i },
}},
));
}
const equippedItem = InventoryGet(C, focusGroup.Name);
for (const [i, button] of Array.from(buttonGrid.children).entries()) {
const clickedItem = DialogInventory[i];
const status = this.GetClickStatus(C, clickedItem, equippedItem);
if (status) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
const permissions = C.PermissionItems[`${clickedItem.Asset.Group.Name}/${clickedItem.Asset.Name}`];
button.setAttribute("data-permission", permissions?.Permission ?? "Default");
if (!options.reset) {
// Tasks already performed during a full `options.reset`
button.toggleAttribute("data-hidden", CharacterAppearanceItemIsHidden(clickedItem.Asset.Name, clickedItem.Asset.Group.Name));
ElementButton.ReloadAssetIcons(/** @type {HTMLButtonElement} */(button), clickedItem, C);
}
}
if (options.resetScrollbar) {
buttonGrid.scrollTo({ top: 0, behavior: "instant" });
}
}
/** @type {_DialogFocusMenu["_ReloadIcon"]} */
_ReloadIcon(root, icon, properties, options) { /** noop */ }
/** @type {_DialogFocusMenu["_ReloadMenubar"]} */
_ReloadMenubar(root, menubar, properties, options) { /** noop */ }
/** @type {DialogMenu<string, DialogInventoryItem>["_GetClickedObject"]} */
_GetClickedObject(button) {
return DialogInventory[Number.parseInt(button.getAttribute("data-index"), 10)];
}
/** @type {DialogMenu<string, DialogInventoryItem>["_ClickButton"]} */
_ClickButton(button, C, clickedObj, equippedItem) {
const permission = DialogPermissionsClick(clickedObj, equippedItem);
button.setAttribute("data-permission", permission);
ElementButton.ReloadAssetIcons(button, clickedObj, C);
}
}
/**
* @template {string} T
* @extends {_DialogFocusMenu<T, ItemActivity>}
*/
class _DialogActivitiesMenu extends _DialogFocusMenu {
ids = Object.freeze({
root: "dialog-activity",
status: "dialog-activity-status",
grid: "dialog-activity-grid",
paginate: "dialog-activity-paginate",
});
_initPropertyNames = /** @type {const} */(["C", "focusGroup"]);
/** @type {DialogMenu<T, ItemActivity>["clickStatusCallbacks"]} */
clickStatusCallbacks = {
IsBlocked: (C, clickedActivity, equippedItem) => {
return clickedActivity.Blocked === "blocked" ? InterfaceTextGet("ExtendedItemNoItemPermission") : null;
},
};
_Load() {
const ids = this.ids;
return document.getElementById(ids.root) ?? ElementCreate({
tag: "div",
children: [
{
tag: "span",
attributes: { id: ids.status, "aria-hidden": "true" },
classList: ["dialog-status", "scroll-box"],
},
this._ConstructPaginateButtons(this.ids.paginate),
{
tag: "div",
attributes: { id: ids.grid, "aria-labelledby": ids.status },
classList: ["dialog-grid", "scroll-box"],
// FIXME: Figure out how the let the listeners distinguish between mouse wheels and touch pads,
// as the latter really needs smooth scrolling behavior while the former needs instant
//
// eventListeners: { wheel: this.eventListeners._WheelGrid },
},
],
});
}
/** @type {_DialogFocusMenu["_ReloadStatus"]} */
_ReloadStatus(root, span, properties, options) {
const { C, focusGroup } = properties;
const textContent = options.status ?? InterfaceTextGet("SelectActivityGroup");
DialogSetStatus(textContent, options.statusTimer ?? 0, { C, group: focusGroup });
}
/** @type {_DialogFocusMenu["_ReloadButtonGrid"]} */
_ReloadButtonGrid(root, buttonGrid, properties, options) {
const { C, focusGroup } = properties;
if (options.resetDialogItems) {
DialogBuildActivities(C, false);
}
if (options.reset) {
buttonGrid.innerHTML = "";
DialogActivity.forEach((clickedActivity, index) => ElementButton.CreateForActivity(
`dialog-activities-${index}`,
clickedActivity,
C,
this.eventListeners._ClickButton,
{ clickDisabled: this.eventListeners._ClickDisabledButton },
{ button: {
parent: buttonGrid,
classList: ["dialog-grid-button"],
attributes: { "screen-generated": undefined },
dataAttributes: { index, group: focusGroup.Name },
}},
));
}
const equippedItem = InventoryGet(C, focusGroup.Name);
for (const [i, button] of Array.from(buttonGrid.children).entries()) {
const clickedActivity = DialogActivity[i];
const status = this.GetClickStatus(C, clickedActivity, equippedItem);
if (status) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
}
if (options.resetScrollbar) {
buttonGrid.scrollTo({ top: 0, behavior: "instant" });
}
}
/** @type {_DialogFocusMenu["_ReloadIcon"]} */
_ReloadIcon() { /** noop */ }
/** @type {_DialogFocusMenu["_ReloadMenubar"]} */
_ReloadMenubar(root, menubar, properties, options) { /** noop */ }
/** @type {DialogMenu<string, ItemActivity>["_GetClickedObject"]} */
_GetClickedObject(button) {
return DialogActivity[Number.parseInt(button.getAttribute("data-index"), 10)];
}
/** @type {DialogMenu<string, ItemActivity>["_ClickButton"]} */
_ClickButton(button, C, clickedObj, equippedItem) {
DialogActivityClick(C, clickedObj, equippedItem);
}
}
/**
* @template {string} T
* @extends {_DialogFocusMenu<T, null>}
*/
class _DialogCraftedMenu extends _DialogFocusMenu {
ids = Object.freeze({
root: "dialog-crafted",
status: "dialog-crafted-status",
icon: "dialog-crafted-icon",
footer: "dialog-crafted-footer",
info: "dialog-crafted-info",
description: "dialog-crafted-description",
private: "dialog-crafted-private",
name: "dialog-crafted-name",
property: "dialog-crafted-property",
crafter: "dialog-crafted-crafter",
gap: "dialog-crafted-gap",
});
_initPropertyNames = /** @type {const} */(["C", "focusGroup"]);
/** @type {DialogMenu["clickStatusCallbacks"]} */
clickStatusCallbacks = {};
_Load() {
const ids = this.ids;
return document.getElementById(ids.root) ?? ElementCreate({
tag: "div",
attributes: { "aria-owns": ids.icon },
children: [
{
tag: "span",
attributes: { id: ids.status, "aria-hidden": "true" },
classList: ["dialog-status", "scroll-box"],
},
{
tag: "ul",
attributes: { id: ids.info, "aria-labelledby": ids.status },
children: [
{ tag: "li", attributes: { id: ids.name }, classList: [ids.info] },
{ tag: "li", attributes: { id: ids.crafter }, classList: [ids.info] },
{ tag: "li", attributes: { id: ids.property }, classList: [ids.info] },
{ tag: "li", attributes: { id: ids.private }, classList: [ids.info] },
{ tag: "div", attributes: { id: ids.gap, "aria-hidden": "true" } },
{
tag: "div",
classList: ["button", "blank-button", "button-styling", "dialog-grid-button", "dialog-icon"],
attributes: { id: ids.icon, role: "img", "aria-labelledby": `${ids.icon}-label` },
},
{
tag: "li",
attributes: { id: ids.footer },
classList: [ids.info, "scroll-box"],
children: [
{
tag: "ul",
children: [
{ tag: "li", attributes: { id: ids.description } },
],
},
]
}
],
},
],
});
}
/** @type {_DialogFocusMenu["_ReloadStatus"]} */
_ReloadStatus(root, span, properties, options) {
const textContent = options.status ?? InterfaceTextGet("CraftedItemProperties");
DialogSetStatus(textContent, options.statusTimer ?? 0);
}
/** @type {_DialogFocusMenu["_ReloadButtonGrid"]} */
_ReloadButtonGrid(root, buttonGrid, properties, options) { /** noop */ }
/** @type {_DialogFocusMenu["_ReloadIcon"]} */
_ReloadIcon(root, icon, properties, options) {
const { C, focusGroup } = properties;
const ids = this.ids;
const item = InventoryGet(C, focusGroup.Name);
icon.innerHTML = "";
[
ElementButton._ParseImage(icon.id, `./Assets/Female3DCG/${item.Asset.DynamicGroupName}/Preview/${item.Asset.Name}.png`),
ElementButton._ParseLabel(icon.id, item.Asset.Description),
ElementButton._ParseIcons(icon.id, [
DialogGetFavoriteStateDetails(C, item.Asset)?.Icon,
...DialogGetLockIcon(item, true),
...DialogGetAssetIcons(item.Asset),
...DialogEffectIcons.GetIcons(item)
])?.iconGrid,
].filter(Boolean).forEach(e => icon.append(e));
const [name, crafter, property, description, private_] = [ids.name, ids.crafter, ids.property, ids.description, ids.private].map(i => document.getElementById(i));
name.textContent = InterfaceTextGet("CraftingName").replace("CraftName", item.Craft.Name);
crafter.textContent = InterfaceTextGet("CraftingMember").replace("MemberName", item.Craft.MemberName).replace("MemberNumber", item.Craft.MemberNumber.toString());
private_.textContent = InterfaceTextGet("CraftingPrivate").replace("CraftPrivate", CommonCapitalize(item.Craft.Private.toString()));
TextPrefetchFile("Screens/Room/Crafting/Text_Crafting.csv").loadedPromise.then(textCache => {
property.replaceChildren(
InterfaceTextGet("CraftingProperty").replace("CraftProperty", ""),
ElementCreate({ tag: "dfn", children: [item.Craft.Property] }),
" - ",
textCache.get(`Description${item.Craft.Property}`),
);
});
description.replaceChildren(
InterfaceTextGet("CraftingDescription").replace("CraftDescription", ""),
...CraftingDescription.DecodeToHTML(item.Craft.Description),
);
}
/** @type {_DialogFocusMenu["_ReloadMenubar"]} */
_ReloadMenubar(root, menubar, properties, options) { /** noop */ }
/** @type {DialogMenu<string, null>["_GetClickedObject"]} */
_GetClickedObject(button) { return null; /** noop */ }
/** @type {DialogMenu<string, null>["_ClickButton"]} */
_ClickButton(button, C, clickedObj, equippedItem) { /** noop */ }
}
/**
* @template {string} T
* @extends {DialogMenu<T, DialogLine, { C: Character }>}
*/
class _DialogDialogMenu extends DialogMenu {
ids = Object.freeze({
root: "dialog-dialog",
status: "dialog-dialog-status",
grid: "dialog-dialog-grid",
menubar: "dialog-dialog-menubar",
});
_initPropertyNames = /** @type {const} */(["C"]);
defaultShape = Object.freeze(/** @type {const} */([1005, 15, 995, 962]));
/** @type {DialogMenu<string, DialogLine>["clickStatusCallbacks"]} */
clickStatusCallbacks = {};
/**
* @param {T} mode
*/
constructor(mode) {
super(mode);
const dialogMenu = this;
this.eventListeners = {
...this.eventListeners,
/** @type {(this: HTMLButtonElement, ev: MouseEvent) => void} */
_ClickMenubarExit: function(ev) {
const C = dialogMenu.C;
if (!C) {
ev.stopImmediatePropagation();
return;
}
switch (DialogIntro(C)) {
case "":
case "NOEXIT":
this.setAttribute("aria-disabled", "true");
this.dispatchEvent(new MouseEvent("bcClickDisabled", ev));
ev.stopImmediatePropagation();
break;
default:
DialogMenuBack();
break;
}
},
};
}
/** @type {DialogMenu["Draw"]} */
Draw() {
NPCInteraction(this.C);
}
_Load() {
const ids = this.ids;
return document.getElementById(ids.root) ?? ElementCreate({
tag: "div",
children: [
ElementMenu.Create(
ids.menubar,
[],
{ direction: "rtl" },
{ menu: { classList: ["dialog-menubar"] } },
),
{
tag: "span",
attributes: { id: ids.status, "aria-hidden": "true" },
classList: ["dialog-status", "scroll-box"],
},
{
tag: "div",
attributes: { id: ids.grid, "aria-labelledby": ids.status },
classList: ["dialog-grid", "scroll-box"],
},
],
});
}
/**
* A {@link clearTimeout}-returned ID for temporarily disabling the exit button on mobile button clicks
* @private
* @type {null | number}
*/
_mobileTimeoutID = null;
/**
* @private
* @satisfies {TimerHandler}
*/
_mobileTimeoutHandler() {
document.querySelector(`#${this.ids.menubar} [name='Exit']`)?.removeAttribute("disabled");
this._mobileTimeoutID = null;
}
/** @type {ScreenFunctions["Exit"]} */
Exit() {
super.Exit();
if (this._mobileTimeoutID != null) {
clearTimeout(this._mobileTimeoutID);
this._mobileTimeoutHandler();
}
}
/** @type {ScreenFunctions["Unload"]} */
Unload() {
super.Unload();
if (this._mobileTimeoutID != null) {
clearTimeout(this._mobileTimeoutID);
this._mobileTimeoutHandler();
}
}
/** @type {DialogMenu["_ReloadStatus"]} */
_ReloadStatus(root, span, properties, options) {
const { C } = properties;
const textContent = SpeechTransformDialog(C, options.status ?? C.CurrentDialog);
DialogSetStatus(textContent, options.statusTimer ?? 0);
}
/** @type {DialogMenu["_ReloadButtonGrid"]} */
_ReloadButtonGrid(root, buttonGrid, properties, options) {
const { C } = properties;
if (options.reset) {
buttonGrid.replaceChildren();
buttonGrid.append(
...C.Dialog.map((dialog, i) => ElementButton.Create(
`dialog-dialog-${i}`,
this.eventListeners._ClickButton,
{ label: " ", labelPosition: "center" },
{ button: {
attributes: { "screen-generated": undefined },
dataAttributes: { index: i, group: dialog.Group },
classList: ["dialog-grid-button", "dialog-dialog-button"],
}},
)),
);
}
for (const [i, dialog] of C.Dialog.entries()) {
const button = buttonGrid.children[i];
if (!button) {
continue;
}
const unload = !(dialog.Stage === C.Stage && dialog.Option != null && DialogPrerequisite(dialog));
button.toggleAttribute("data-unload", unload);
if (!unload) {
button.querySelector(".button-label")?.replaceChildren(SpeechTransformDialog(Player, dialog.Option));
}
}
}
/** @type {DialogMenu["_ReloadIcon"]} */
_ReloadIcon(root, icon, propertes, options) { /** noop */ }
/** @type {DialogMenu["_ReloadMenubar"]} */
_ReloadMenubar(root, menubar, properties, options) {
const { C } = properties;
if (options.reset) {
menubar.replaceChildren();
ElementMenu.AppendButton(
menubar,
ElementButton.Create(
`${this.ids.menubar}-Exit`,
this.eventListeners._ClickMenubarExit,
{ image: "./Icons/Exit.png", tooltip: InterfaceTextGet("DialogMenuExit"), tooltipPosition: "left" },
{ button: {
attributes: { "screen-generated": undefined, name: "Exit", disabled: CommonIsMobile ? "" : undefined },
classList: ["dialog-menubar-button"],
}},
),
);
// TODO: Remove once the other dialog menubar have all been DOM'ified
// Prevent certain mobile phone types from instantly clicking DOM buttons placed behind a previously-clicked canvas button
if (CommonIsMobile) {
if (this._mobileTimeoutID != null) {
clearTimeout(this._mobileTimeoutID);
}
this._mobileTimeoutID = setTimeout(this._mobileTimeoutHandler.bind(this), 150);
}
}
const exitButton = menubar.querySelector("[name='Exit']");
switch (DialogIntro(C)) {
case "":
case "NOEXIT":
exitButton?.setAttribute("aria-disabled", "true");
break;
default:
exitButton?.setAttribute("aria-disabled", "false");
break;
}
}
/** @type {DialogMenu<string, DialogLine>["_GetClickedObject"]} */
_GetClickedObject(button) {
const index = Number.parseInt(button.getAttribute("data-index"), 10);
return this.C?.Dialog[index] ?? null;
}
/** @type {DialogMenu<string, DialogLine>["_ClickButton"]} */
_ClickButton(button, C, clickedDialog) {
// Use the private `_CurrentDialog`/`_Reload` variables in order to circumenvent the respective setters,
// manually performing any reloads/status updates
// If the player is gagged, the answer will always be the same
C._CurrentDialog = Player.CanTalk() ? clickedDialog.Result : DialogFind(C, "PlayerGagged");
C.ClickedOption = clickedDialog.Option;
// A dialog option can change the conversation stage, show text or launch a custom function
if ((Player.CanTalk() && C.CanTalk()) || SpeechFullEmote(clickedDialog.Option)) {
C._CurrentDialog = clickedDialog.Result;
if (clickedDialog.NextStage != null) {
C._Stage = clickedDialog.NextStage;
this.Reload();
} else {
// manually reload the status
DialogSetStatus(C.CurrentDialog);
}
if (typeof clickedDialog.Function === "string") {
CommonDynamicFunctionParams(clickedDialog.Function);
}
} else if (clickedDialog.Function?.trim() === "DialogLeave()") {
DialogLeave();
}
}
}
/** @satisfies {Partial<Record<DialogMenuMode, DialogMenu<DialogMenuMode>>>} */
var DialogMenuMapping = /** @type {const} */({
activities: new _DialogActivitiesMenu("activities"),
crafted: new _DialogCraftedMenu("crafted"),
dialog: new _DialogDialogMenu("dialog"),
items: new _DialogItemMenu("items"),
locked: new _DialogItemMenu("locked"),
locking: new _DialogLockingMenu("locking"),
permissions: new _DialogPermissionMenu("permissions"),
});
/**
* Searches in the dialog for a specific stage keyword and returns that dialog option if we find it, error otherwise
* @param {string} KeyWord - The key word to search for
* @returns {string}
*/
function DialogFindPlayer(KeyWord) {
const res = PlayerDialog.get(KeyWord);
return res !== undefined ? res : `${TEXT_NOT_FOUND_PREFIX} "DialogPlayer.csv": ${KeyWord}`;
}
/**
* Searches in the dialog for a specific stage keyword and returns that dialog option if we find it
* @param {Character} C - The character whose dialog optio*
* @param {string} KeyWord1 - The key word to search for
* @param {string} [KeyWord2] - An optionally given second key word. is only looked for, if specified and the first
* keyword was not found.
* @param {boolean} [ReturnPrevious=true] - If specified, returns the previous dialog, if neither of the the two key words were found
ns should be searched
* @returns {string} - The name of a dialog. That can either be the one with the keyword or the previous dialog.
* An empty string is returned, if neither keyword was found and no previous dialog was given.
*/
function DialogFind(C, KeyWord1, KeyWord2, ReturnPrevious = true) {
for (let D = 0; D < C.Dialog.length; D++)
if (C.Dialog[D].Stage == KeyWord1)
return C.Dialog[D].Result.trim();
if (KeyWord2 != null)
for (let D = 0; D < C.Dialog.length; D++)
if (C.Dialog[D].Stage == KeyWord2)
return C.Dialog[D].Result.trim();
return ((ReturnPrevious == null) || ReturnPrevious) ? C.CurrentDialog : "";
}
/**
* Searches in the dialog for a specific stage keyword and returns that dialog option if we find it and replace the names
* @param {Character} C - The character whose dialog options should be searched
* @param {string} KeyWord1 - The key word to search for
* @param {string} [KeyWord2] - An optionally given second key word. is only looked for, if specified and the first
* keyword was not found.
* @param {boolean} [ReturnPrevious] - If specified, returns the previous dialog, if neither of the the two key words were found
* @returns {string} - The name of a dialog. That can either be the one with the keyword or the previous dialog.
* An empty string is returned, if neither keyword was found and no previous dialog was given. 'SourceCharacter'
* is replaced with the player's name and 'DestinationCharacter' with the current character's name.
*/
function DialogFindAutoReplace(C, KeyWord1, KeyWord2, ReturnPrevious) {
return DialogFind(C, KeyWord1, KeyWord2, ReturnPrevious)
.replace("SourceCharacter", CharacterNickname(Player))
.replace("DestinationCharacter", CharacterNickname(CharacterGetCurrent()));
}
/**
* Draw the up/down arrow to bump a character up and down if they're hidden.
*/
function DialogDrawRepositionButton() {
if (!CurrentCharacter.HeightModifier == null || !CurrentCharacter.FocusGroup) return;
let drawButton = "";
if (CharacterAppearanceForceUpCharacter == CurrentCharacter.MemberNumber) {
drawButton = "Icons/Remove.png";
} else if (CurrentCharacter.HeightModifier < -90) {
drawButton = CurrentCharacter.IsInverted() ? "Icons/Down.png" : "Icons/Up.png";
} else if (CurrentCharacter.HeightModifier > 30) {
drawButton = CurrentCharacter.IsInverted() ? "Icons/Up.png" : "Icons/Down.png";
}
if (drawButton)
DrawButton(510, 50, 90, 90, "", "White", drawButton, InterfaceTextGet("ShowAllZones"));
}
/**
* Draws the top menu buttons of the current dialog.
*
* @param {Character} C The character currently focused.
*/
function DialogDrawTopMenu(C) {
const FocusItem = InventoryGet(C, C.FocusGroup.Name);
for (let I = DialogMenuButton.length - 1; I >= 0; I--) {
const ButtonColor = DialogGetMenuButtonColor(DialogMenuButton[I]);
const ButtonImage = DialogGetMenuButtonImage(DialogMenuButton[I], FocusItem);
const ButtonHoverText = InterfaceTextGet(`DialogMenu${DialogMenuButton[I]}`);
const ButtonDisabled = DialogIsMenuButtonDisabled(DialogMenuButton[I]);
DrawButton(1885 - I * 110, 15, 90, 90, "", ButtonColor, "Icons/" + ButtonImage + ".png", ButtonHoverText, ButtonDisabled);
}
}
var DialogFocusGroup = {
/**
*
* @param {string} id - The ID for the to-be created focus group grid
* @param {(this: HTMLButtonElement, ev: MouseEvent) => any} listener - The listener to-be executed upon selecting a group; the group name can be retrieved from `this.name`
* @param {null | { required?: boolean, useDynamicGroupName?: boolean }} options - Further options for the to-be created focus group grid
* @returns {HTMLElement} - The created element
*/
Create(id, listener, options=null) {
options ??= {};
const root = document.getElementById(id);
if (root) {
console.error(`Element "${id}" already exists`);
return root;
}
let top = Infinity;
let bottom = 0;
let left = Infinity;
let right = 0;
/** @type {{ group: AssetGroup, index: number, zone: RectTuple }[]} */
const grid = [];
for (const group of AssetGroup) {
for (const [index, zone] of (group.Zone ?? []).entries()) {
grid.push({ group, index, zone});
left = Math.min(left, zone[0]);
right = Math.max(right, zone[0] + zone[2]);
top = Math.min(top, zone[1]);
bottom = Math.max(bottom, zone[1] + zone[3]);
}
}
grid.sort((a, b) => (a.zone[1] - b.zone[1]) || (a.zone[0] - b.zone[0]));
const width = right - left;
const height = bottom - top;
const children = grid.map(({ group, index, zone }, i) => ElementButton.Create(
`${id}-${group.Name}-${index}`,
listener,
{ noStyling: true, role: "radio" },
{ button: {
attributes: {
name: options.useDynamicGroupName ? group.DynamicGroupName : group.Name,
tabindex: i === 0 ? 0 : -1,
"aria-hidden": index !== 0 ? "true" : undefined,
"aria-label": group.Description,
},
style: {
left: `${100 * (zone[0] - left) / width}%`,
top: `${100 * (zone[1] - top) / height}%`,
width: `${100 * (zone[2] / width)}%`,
height: `${100 * (zone[3] / height)}%`,
},
}},
));
return ElementCreate({
tag: "div",
attributes: { id },
classList: ["dialog-focus-grid"],
children: [{
tag: "div",
children,
attributes: {
id: `${id}-radiogroup`,
role: "radiogroup",
"aria-required": options.required ? "true" : "false",
"aria-label": "Select focus group",
},
}],
});
},
};
/**
* Draws the left menu for the character
* @param {Character} C - The currently focused character
*/
function DialogSelfMenuDraw(C) {
if (!C.IsPlayer()) return;
if (DialogSelfMenuOptions.filter(SMO => SMO.IsAvailable()).length > 1 && !CommonPhotoMode)
DrawButton(420, 50, 90, 90, "", "White", "Icons/Next.png", InterfaceTextGet("NextPage"));
if (!DialogSelfMenuSelected)
DialogDrawExpressionMenu();
else
DialogSelfMenuSelected.Draw();
}
/**
* Handles clicks on the left menu
* @param {Character} C - The currently focused character
*/
function DialogSelfMenuClick(C) {
if (!C.IsPlayer()) return;
if (MouseIn(420, 50, 90, 90) && DialogSelfMenuOptions.filter(SMO => SMO.IsAvailable()).length > 1) {
DialogFindNextSubMenu();
return;
}
if (!DialogSelfMenuSelected)
DialogClickExpressionMenu();
else
DialogSelfMenuSelected.Click();
}
/**
* Load function for starting the Dialog subscreen.
* @type {ScreenFunctions["Load"]}
*/
function DialogLoad() {
if (CurrentCharacter == null) {
return;
}
const C = CurrentCharacter;
const newDialog = !Player.CanTalk() ? DialogFind(C, "PlayerGagged", "") : DialogIntro(C);
if (newDialog) {
C._CurrentDialog = newDialog;
}
DialogChangeMode(DialogMenuMode ?? "dialog", true);
}
/**
* Draws the initial Dialog screen.
*
* This is the main handler for drawing the Dialog UI, which activates
* when the player clicks on herself or another player.
*
* @type {ScreenFunctions["Draw"]}
*/
function DialogDraw() {
// Customization can be used in dialog if screen is online chat room
if ((CurrentScreen == "ChatRoom") && ChatRoomCustomized) {
const drawBGToRect = DrawShowChatRoomCustomBackground() ? { x: 0, y: 0, w: 2000, h: 1000 } : null;
ChatAdminRoomCustomizationProcess(ChatRoomData.Custom, drawBGToRect, true);
}
// Check that there's actually a character selected
if (!CurrentCharacter) return;
if (ControllerIsActive()) {
ControllerClearAreas();
}
// Draw both the player and the interaction character
if (!CurrentCharacter.IsPlayer())
DrawCharacter(Player, 0, 0, 1);
DrawCharacter(CurrentCharacter, 500, 0, 1);
const C = CharacterGetCurrent();
// Drawing the character causes ScriptDraw hooks to be called, some of which
// can cause the dialog to close (like the Futuristic Training Belt)
if (!C) return;
CharacterCheckHooks(C, true);
DialogSelfMenuDraw(C);
// Block out drawing anything else if we're not supposed to interact with the selected character
if (
(C.FocusGroup === null && DialogMenuMode !== "dialog")
|| !C.AllowItem
) return;
/** The item currently sitting in the focused group */
const FocusItem = InventoryGet(C, C.FocusGroup?.Name);
// Draws the top menu text & icons
if (!["extended", "tighten", "colorExpression", "colorItem", "layering", "dialog"].includes(DialogMenuMode))
DialogDrawTopMenu(C);
// If the player is struggling or lockpicking
if (DialogMenuMode === "struggle") {
if (StruggleMinigameDraw(C)) {
// Minigame running and drawn
} else {
// Draw previews for the assets we're swapping/struggling
if (DialogStrugglePrevItem && DialogStruggleNextItem) {
DrawItemPreview(DialogStrugglePrevItem, C, 1200, 100);
DrawItemPreview(DialogStruggleNextItem, C, 1200, 100);
} else if (DialogStrugglePrevItem || DialogStruggleNextItem) {
const item = DialogStrugglePrevItem || DialogStruggleNextItem;
DrawItemPreview(item, C, 1387, 100);
}
// Draw UI to select struggling minigame
DrawText(InterfaceTextGet("ChooseStruggleMethod"), 1500, 500, "White", "Black");
for (const [idx, [game, data]] of StruggleGetMinigames().entries()) {
const offset = 300 * idx;
const hover = MouseIn(1087 + offset, 550, 225, 275);
const disabled = data.DisablingCraftedProperty ? InventoryCraftPropertyIs(DialogStrugglePrevItem, data.DisablingCraftedProperty) : false;
const bgColor = disabled ? "Gray" : (hover ? "aqua" : "white");
DrawRect(1087 + offset, 550, 225, 275, bgColor);
DrawImageResize("Icons/Struggle/" + game + ".png", 1089 + offset, 552, 221, 221);
DrawTextFit(InterfaceTextGet(`Struggle${game}`), 1200 + offset, 800, 221, "black");
}
// In online chat rooms, there's a fourth "Chat Room Struggle" option
if (CurrentScreen === "ChatRoom") {
DrawButton(1300, 880, 400, 65, InterfaceTextGet("ChatRoomStruggleButton"), "White");
}
}
return;
}
if (DialogMenuMode === "extended") {
if (DialogFocusItem) {
CommonDynamicFunction("Inventory" + DialogFocusItem.Asset.Group.Name + DialogFocusItem.Asset.Name + "Draw()");
DrawButton(1885, 25, 90, 90, "", "White", "Icons/Exit.png");
return;
} else {
DialogChangeMode("items");
}
} else if (DialogMenuMode === "tighten") {
if (DialogTightenLoosenItem) {
TightenLoosenItemDraw();
return;
} else {
DialogChangeMode("items");
}
} else if ((DialogMenuMode === "colorItem" || DialogMenuMode === "colorExpression") && FocusItem) {
ItemColorDraw(C, C.FocusGroup.Name, 1200, 25, 775, 950, true);
return;
} else if (DialogMenuMode === "colorDefault") {
ElementPosition("InputColor", 1450, 65, 300);
ColorPickerDraw(1300, 145, 675, 830,
/** @type {HTMLInputElement} */(document.getElementById("InputColor")),
function (Color) { DialogChangeItemColor(C, Color); });
return;
}
// Draw a repositioning button if some zones are offscreen
DialogDrawRepositionButton();
DialogMenuMapping[DialogMenuMode]?.Draw();
}
/**
* Draw the menu for changing facial expressions
* @returns {void} - Nothing
*/
function DialogDrawExpressionMenu() {
// Draw the expression groups
DrawText(InterfaceTextGet("FacialExpression"), 165, 25, "White", "Black");
if (typeof DialogFacialExpressionsSelected === 'number' && DialogFacialExpressionsSelected >= 0 && DialogFacialExpressionsSelected < DialogFacialExpressions.length && DialogFacialExpressions[DialogFacialExpressionsSelected].Appearance.Asset.Group.AllowColorize && DialogFacialExpressions[DialogFacialExpressionsSelected].Group !== "Eyes") {
DrawButton(320, 50, 90, 90, "", "White", "Icons/ColorChange.png", InterfaceTextGet("DialogMenuColorExpressionChange"));
}
DrawButton(220, 50, 90, 90, "", "White", "Icons/BlindToggle" + DialogFacialExpressionsSelectedBlindnessLevel + ".png", InterfaceTextGet("BlindToggleFacialExpressions"));
const Expression = WardrobeGetExpression(Player);
const Eye1Closed = Expression.Eyes === "Closed";
const Eye2Closed = Expression.Eyes2 === "Closed";
let WinkIcon = "WinkNone";
if (Eye1Closed && Eye2Closed) WinkIcon = "WinkBoth";
else if (Eye1Closed) WinkIcon = "WinkR";
else if (Eye2Closed) WinkIcon = "WinkL";
DrawButton(120, 50, 90, 90, "", "White", `Icons/${WinkIcon}.png`, InterfaceTextGet("WinkFacialExpressions"));
DrawButton(20, 50, 90, 90, "", "White", "Icons/Reset.png", InterfaceTextGet("ClearFacialExpressions"));
if (!DialogFacialExpressions || !DialogFacialExpressions.length) DialogFacialExpressionsBuild();
for (const [i, FE] of DialogFacialExpressions.entries()) {
const OffsetY = 185 + 100 * i;
DrawButton(20, OffsetY, 90, 90, "", i == DialogFacialExpressionsSelected ? "Cyan" : "White", "Assets/Female3DCG/" + FE.Group + (FE.CurrentExpression ? "/" + FE.CurrentExpression : "") + "/Icon.png");
// Draw the table with expressions
if (i == DialogFacialExpressionsSelected) {
const nPages = Math.ceil(FE.ExpressionList.length / DialogFacialExpressionsPerPage);
if (nPages > 1) {
DrawButton(155, 785, 90, 90, "", "White", `Icons/Prev.png`, InterfaceTextGet("PrevExpressions"));
DrawButton(255, 785, 90, 90, "", "White", `Icons/Next.png`, InterfaceTextGet("NextExpressions"));
}
const expressionSubset = FE.ExpressionList.slice(
DialogFacialExpressionsSelectedPage * DialogFacialExpressionsPerPage,
DialogFacialExpressionsSelectedPage * DialogFacialExpressionsPerPage + DialogFacialExpressionsPerPage,
);
for (const [j, expression] of expressionSubset.entries()) {
const EOffsetX = 155 + 100 * (j % 3);
const EOffsetY = 185 + 100 * Math.floor(j / 3);
const allowed = CharacterIsExpressionAllowed(Player, FE.Appearance, expression);
const color = (expression == FE.CurrentExpression ? "Pink" : (!allowed ? "#888" : "White"));
const icon = "Assets/Female3DCG/" + FE.Group + (expression ? "/" + expression : "") + "/Icon.png";
DrawButton(EOffsetX, EOffsetY, 90, 90, "", color, icon);
}
}
}
}
/**
* Return the page number of the expression item's current expression.
* @param {ExpressionItem} item - The expression item
* @returns {number} The page number of the item's current expression
*/
function DialogGetCurrentExpressionPage(item) {
const index = item.ExpressionList.findIndex(name => item.CurrentExpression === name);
return index === -1 ? 0 : Math.floor(index / DialogFacialExpressionsPerPage);
}
/**
* Handles clicks in the dialog expression menu.
* @returns {void} - Nothing
*/
function DialogClickExpressionMenu() {
if (MouseIn(20, 50, 90, 90)) {
CharacterResetFacialExpression(Player);
DialogFacialExpressions.forEach(FE => {
// Reset the fluids' custom color
if (FE.Group === "Fluids") {
FE.Appearance.Color = "Default";
}
FE.CurrentExpression = null;
});
if (DialogMenuMode === "colorExpression") ItemColorSaveAndExit();
} else if (MouseIn(120, 50, 90, 90)) {
const CurrentExpression = DialogFacialExpressions.find(FE => FE.Group == "Eyes").CurrentExpression;
const EyesExpression = WardrobeGetExpression(Player);
const LeftEyeClosed = EyesExpression.Eyes2 === "Closed";
const RightEyeClosed = EyesExpression.Eyes === "Closed";
if (!LeftEyeClosed && !RightEyeClosed) CharacterSetFacialExpression(Player, "Eyes2", "Closed", null);
else if (LeftEyeClosed && !RightEyeClosed) CharacterSetFacialExpression(Player, "Eyes", "Closed", null);
else if (LeftEyeClosed && RightEyeClosed) CharacterSetFacialExpression(Player, "Eyes2", CurrentExpression !== "Closed" ? CurrentExpression : null, null);
else CharacterSetFacialExpression(Player, "Eyes", CurrentExpression !== "Closed" ? CurrentExpression : null, null);
} else if (MouseIn(220, 50, 90, 90)) {
DialogFacialExpressionsSelectedBlindnessLevel += 1;
if (DialogFacialExpressionsSelectedBlindnessLevel > 3)
DialogFacialExpressionsSelectedBlindnessLevel = 1;
} else if (MouseIn(320, 50, 90, 90)) {
if (typeof DialogFacialExpressionsSelected === 'number' && DialogFacialExpressionsSelected >= 0 && DialogFacialExpressionsSelected < DialogFacialExpressions.length && DialogFacialExpressions[DialogFacialExpressionsSelected].Appearance.Asset.Group.AllowColorize && DialogFacialExpressions[DialogFacialExpressionsSelected].Group !== "Eyes") {
DialogChangeMode("colorExpression");
const GroupName = DialogFacialExpressions[DialogFacialExpressionsSelected].Appearance.Asset.Group.Name;
const Item = InventoryGet(Player, GroupName);
const originalColor = Item.Color;
Player.FocusGroup = /** @type {AssetItemGroup} */ (AssetGroupGet(Player.AssetFamily, GroupName));
ItemColorLoad(Player, Item, 1200, 25, 775, 950, true);
ItemColorOnExit((save) => {
DialogMenuBack();
if (save && !CommonColorsEqual(originalColor, Item.Color)) {
ServerPlayerAppearanceSync();
ChatRoomCharacterItemUpdate(Player, GroupName);
}
});
}
} else {
// Expression category buttons
for (let I = 0; I < DialogFacialExpressions.length; I++) {
if (MouseIn(20, 185 + 100 * I, 90, 90)) {
if (DialogFacialExpressionsSelected !== I) {
DialogFacialExpressionsSelected = I;
DialogFacialExpressionsSelectedPage = DialogGetCurrentExpressionPage(DialogFacialExpressions[I]);
}
if (DialogMenuMode === "colorExpression") ItemColorSaveAndExit();
}
}
// Expression table
if (DialogFacialExpressionsSelected >= 0 && DialogFacialExpressionsSelected < DialogFacialExpressions.length) {
const FE = DialogFacialExpressions[DialogFacialExpressionsSelected];
// Switch pages
const nPages = Math.ceil(FE.ExpressionList.length / DialogFacialExpressionsPerPage);
if (MouseIn(155, 785, 90, 90) && nPages > 1) {
DialogFacialExpressionsSelectedPage = (DialogFacialExpressionsSelectedPage + nPages - 1) % nPages;
return;
} else if (MouseIn(255, 785, 90, 90) && nPages > 1) {
DialogFacialExpressionsSelectedPage = (DialogFacialExpressionsSelectedPage + 1) % nPages;
return;
}
const expressionSubset = FE.ExpressionList.slice(
DialogFacialExpressionsSelectedPage * DialogFacialExpressionsPerPage,
DialogFacialExpressionsSelectedPage * DialogFacialExpressionsPerPage + DialogFacialExpressionsPerPage,
);
for (const [j, expression] of expressionSubset.entries()) {
const EOffsetX = 155 + 100 * (j % 3);
const EOffsetY = 185 + 100 * Math.floor(j / 3);
if (MouseIn(EOffsetX, EOffsetY, 90, 90) && CharacterIsExpressionAllowed(Player, FE.Appearance, expression)) {
CharacterSetFacialExpression(Player, FE.Group, expression);
FE.CurrentExpression = expression;
}
}
}
}
}
/**
* Draws the pose sub menu
* @returns {void} - Nothing
*/
function DialogDrawPoseMenu() {
// Draw the pose groups
DrawText(InterfaceTextGet("PoseMenu"), 250, 100, "White", "Black");
for (const [offsetX, poseGroup] of CommonEnumerate(DialogActivePoses, 140, 140)) {
for (const [offsetY, { Category, Name }] of CommonEnumerate(poseGroup, 180, 100)) {
let isActive = false;
if (Player.ActivePoseMapping[Category] === Name) {
isActive = true;
} else if (Name === "BaseUpper" && !(Player.ActivePoseMapping.BodyUpper || Player.ActivePoseMapping.BodyFull)) {
isActive = true;
} else if (Name === "BaseLower" && !(Player.ActivePoseMapping.BodyLower || Player.ActivePoseMapping.BodyFull)) {
isActive = true;
}
DrawButton(offsetX, offsetY, 90, 90, "", !Player.CanChangeToPose(Name) ? "#888" : isActive ? "Pink" : "White", `Icons/Poses/${Name}.png`);
}
}
}
/**
* Handles clicks in the pose sub menu
* @returns {void} - Nothing
*/
function DialogClickPoseMenu() {
for (const [offsetX, poseGroup] of CommonEnumerate(DialogActivePoses, 140, 140)) {
for (const [offsetY, { Category, Name }] of CommonEnumerate(poseGroup, 180, 100)) {
const isActive = Player.ActivePoseMapping[Category] === Name;
if (MouseIn(offsetX, offsetY, 90, 90) && !isActive && Player.CanChangeToPose(Name)) {
if (ChatRoomOwnerPresenceRule("BlockChangePose", Player)) {
DialogLeave();
return;
}
PoseSetActive(Player, Name);
if (CurrentScreen == "ChatRoom") ServerSend("ChatRoomCharacterPoseUpdate", { Pose: Player.ActivePose });
}
}
}
}
/**
* Sets the current character sub menu to the owner rules
* @returns {void} - Nothing
*/
function DialogViewOwnerRules() {
DialogFindSubMenu("OwnerRules", true);
}
/**
* Draws the rules sub menu
* @returns {void} - Nothing
*/
function DialogDrawOwnerRulesMenu() {
// Draw the pose groups
DrawText(InterfaceTextGet("RulesMenu"), 230, 100, "White", "Black");
/** @type {{Tag: string, Value?: number}[]} */
const ToDisplay = [];
if (LogQuery("BlockOwnerLockSelf", "OwnerRule")) ToDisplay.push({ Tag: "BlockOwnerLockSelf" });
if (LogQuery("BlockChange", "OwnerRule")) ToDisplay.push({ Tag: "BlockChange", Value: LogValue("BlockChange", "OwnerRule") });
if (LogQuery("BlockWhisper", "OwnerRule")) ToDisplay.push({ Tag: "BlockWhisper" });
if (LogQuery("BlockKey", "OwnerRule")) ToDisplay.push({ Tag: "BlockKey" });
if (LogQuery("BlockFamilyKey", "OwnerRule")) ToDisplay.push({ Tag: "BlockFamilyKey" });
if (LogQuery("BlockRemote", "OwnerRule")) ToDisplay.push({ Tag: "BlockRemote" });
if (LogQuery("BlockRemoteSelf", "OwnerRule")) ToDisplay.push({ Tag: "BlockRemoteSelf" });
if (LogQuery("ReleasedCollar", "OwnerRule")) ToDisplay.push({ Tag: "ReleasedCollar" });
if (LogQuery("BlockNickname", "OwnerRule")) ToDisplay.push({ Tag: "BlockNickname" });
if (LogQuery("BlockLoverLockSelf", "LoverRule")) ToDisplay.push({ Tag: "BlockLoverLockSelf" });
if (LogQuery("BlockLoverLockOwner", "LoverRule")) ToDisplay.push({ Tag: "BlockLoverLockOwner" });
if (ToDisplay.length == 0) ToDisplay.push({ Tag: "Empty" });
for (const [i, { Tag, Value }] of ToDisplay.entries()) {
const Y = 180 + 110 * i;
const TextToDraw = InterfaceTextGet(`RulesMenu${Tag}`) + (Value ? ` ${TimerToString(Value - CurrentTime)}` : "");
DrawTextWrap(TextToDraw, 25, Y, 485, 95, "#fff", undefined, 2);
}
}
/**
* Sets the skill ratio for the player, will be a % of effectiveness applied to the skill when using it.
* This way a player can use only a part of her bondage or evasion skill.
* @param {SkillType} SkillType - The name of the skill to influence
* @param {string} NewRatio - The ratio of this skill that should be used
* @returns {void} - Nothing
*/
function DialogSetSkillRatio(SkillType, NewRatio) {
SkillSetRatio(Player, SkillType, parseInt(NewRatio) / 100);
}
/**
* Leave the dialog and revert back to a safe state, when the player uses her safe word
* @returns {void} - Nothing
*/
function DialogChatRoomSafewordRevert() {
DialogLeave();
ChatRoomSafewordRevert();
}
/**
* Leave the dialog and release the player of all restraints before returning them to the Main Lobby
* @returns {void} - Nothing
*/
function DialogChatRoomSafewordRelease() {
DialogLeave();
ChatRoomSafewordRelease();
}
/**
* Close the dialog and switch to the crafting screen.
* @returns {void} - Nothing
*/
function DialogOpenCraftingScreen() {
const FromChatRoom = (CurrentScreen === "ChatRoom");
DialogLeave();
CraftingShowScreen(FromChatRoom);
}
/**
* Check whether it's possible to access the crafting interface.
* @returns {boolean}
*/
function DialogCanCraft() {
if ((CurrentModule != "Online") || (CurrentScreen != "ChatRoom")) return false;
return !Player.IsRestrained() || !Player.IsBlind();
}
/**
* Provides a group's real name for male characters
*
* @param {Character} C
* @param {AssetGroup} G
*/
function DialogActualNameForGroup(C, G) {
let repl = G.Description;
if (C && C.HasPenis() && ["ItemVulva", "ItemVulvaPiercings"].includes(G.Name)) {
repl = G.Name === "ItemVulva" ? InterfaceTextGet("DialogGroupNameItemPenis") : InterfaceTextGet("DialogGroupNameItemGlans");
}
return repl;
}
/**
* Propose one of the struggle minigames or start one automatically.
*
* This function checks the difficulty of the current struggle attempt and
* either use the Strength minigame by default or setup the menu state to show
* the selection screen.
*
* @param {Character} C
* @param {DialogStruggleActionType} Action
* @param {Item} PrevItem
* @param {Item} NextItem
*/
function DialogStruggleStart(C, Action, PrevItem, NextItem) {
// Sets the status struggle and cancels previous struggles
if (ChatRoomStruggleData != null) StruggleChatRoomStop();
ChatRoomStatusUpdate("Struggle");
// If it's not the player struggling, or we're applying a new item, or the
// existing item is locked with a key the character has, or the player can
// interact with it and it's not a mountable item, or the item's difficulty
// is low enough to progress by itself, we're currently trying to swap items
// on someone.
const autoStruggle = (C != Player || PrevItem == null || ((PrevItem != null)
&& (!InventoryItemHasEffect(PrevItem, "Lock", true) || DialogCanUnlock(C, PrevItem))
&& ((Player.CanInteract() && !InventoryItemHasEffect(PrevItem, "Mounted", true))
|| StruggleStrengthGetDifficulty(C, PrevItem, NextItem).auto >= 0)));
if (autoStruggle) {
DialogStruggleAction = Action;
DialogStruggleSelectMinigame = false;
StruggleMinigameStart(C, "Strength", PrevItem, NextItem, DialogStruggleStop);
} else {
DialogStruggleAction = Action;
DialogStrugglePrevItem = PrevItem;
DialogStruggleNextItem = NextItem;
DialogStruggleSelectMinigame = true;
}
DialogChangeMode("struggle");
// Refresh menu buttons
DialogMenuButtonBuild(C);
}
/**
* Handle the struggle minigame completing, either as a failure, an interruption, or a success.
*
* @type {StruggleCompletionCallback}
*/
function DialogStruggleStop(C, Game, { Progress, PrevItem, NextItem, Skill, Attempts, Interrupted, Auto }) {
const Success = Progress >= 100;
if (Interrupted) {
// Handle the minigame having been interrupted by showing a message in chat
let action;
if (PrevItem != null && NextItem != null && !NextItem.Asset.IsLock)
action = "ActionInterruptedSwap";
else if (StruggleProgressNextItem != null)
action = "ActionInterruptedAdd";
else
action = "ActionInterruptedRemove";
ChatRoomPublishAction(C, action, PrevItem, NextItem);
DialogLeave();
return;
} else if (Game === "LockPick") {
if (Success) {
if (C.FocusGroup && C) {
const item = InventoryGet(C, C.FocusGroup.Name);
if (item) {
InventoryUnlock(C, item);
ChatRoomPublishAction(C, "ActionPick", item, null);
}
}
SkillProgress(Player, "LockPicking", Skill);
}
// For an NPC we move out to their reaction, for other characters we return to the item list
if (C.IsNpc()) {
DialogLeaveItemMenu();
} else {
DialogChangeMode("items");
}
return;
} else if (Game === "Dexterity" || Game === "Flexibility" || Game === "Strength") {
if (!Success) {
// Send a stimulation event for that
if (Attempts >= 10 && !Auto && Progress >= 0 && Progress < 100) {
ChatRoomStimulationMessage("StruggleFail");
}
return;
}
// Removes the item & associated items if needed, then wears the new one
InventoryRemove(C, C.FocusGroup.Name);
if (NextItem != null) {
let Color = (DialogColorSelect == null) ? "Default" : DialogColorSelect;
if ((NextItem.Craft != null) && CommonIsColor(NextItem.Craft.Color))
Color = NextItem.Craft.Color;
InventoryWear(C, NextItem.Asset.Name, NextItem.Asset.Group.Name, Color, SkillGetWithRatio(Player, "Bondage"), Player.MemberNumber, NextItem.Craft);
// Refresh the item by getting it back
NextItem = InventoryGet(C, NextItem.Asset.Group.Name);
}
// Handle skills
if (C.IsPlayer()) {
if (NextItem === null) {
// We successfully removed one of our items
SkillProgress(Player, "Evasion", Skill);
} else if (PrevItem === null) {
// We successfully added an item
SkillProgress(Player, "SelfBondage", Skill);
}
} else if (NextItem !== null) {
// We successfully added an item on someone
SkillProgress(Player, "Bondage", Skill);
}
// Reset the the character's position
if (CharacterAppearanceForceUpCharacter == C.MemberNumber) {
CharacterAppearanceForceUpCharacter = -1;
CharacterRefresh(C, false);
}
// Update the dialog state
if (C.IsNpc()) {
// For NPCs, we need to show their reaction and never leave the dialog abruptly
C.CurrentDialog = DialogFind(C, (NextItem == null ? "Remove" + PrevItem.Asset.Name : NextItem.Asset.Name), (NextItem == null ? "Remove" : "") + C.FocusGroup.Name);
DialogLeaveItemMenu();
} else if (NextItem === null) {
// Removing an item, we move back to the menu
ChatRoomPublishAction(C, DialogStruggleAction, PrevItem, NextItem);
DialogChangeMode("items");
} else if (
NextItem !== null
&& NextItem.Asset.Extended
&& (
NextItem.Craft == null
|| NextItem.Craft.TypeRecord == null
|| Object.values(NextItem.Craft.TypeRecord).every(i => i === 0)
)
) {
// Applying an extended, non-crafted/typeless crafted item, refresh the inventory and open the extended UI
ChatRoomPublishAction(C, DialogStruggleAction, PrevItem, NextItem);
DialogExtendItem(NextItem);
} else {
// Applying a non-extended or crafted-with-preset-type item, just exit the dialog altogether
ChatRoomPublishAction(C, DialogStruggleAction, PrevItem, NextItem);
DialogLeave();
}
}
}
/**
* Keyboard handler for dialogs
*
* @type {KeyboardEventListener}
*/
function DialogKeyDown(event) {
if (CurrentCharacter) {
if (StruggleKeyDown(event)) {
return true;
} else if (CommonKey.IsPressed(event, "Escape")) {
DialogMenuBack();
return true;
} else {
return DialogMenuMapping[DialogMenuMode]?.KeyDown(event) ?? false;
}
}
return false;
}
/**
* Mouse down handler for dialogs
*
* @type {MouseEventListener}
*/
function DialogMouseDown(event) {
if (CurrentCharacter) StruggleMouseDown(event);
}