mirror of
https://gitgud.io/BondageProjects/Bondage-College.git
synced 2025-04-25 17:59:34 +00:00
469 lines
16 KiB
JavaScript
469 lines
16 KiB
JavaScript
"use strict";
|
||
|
||
/**
|
||
* Property.js
|
||
* -----------
|
||
* A module with common helper functions for the handling of specific {@link ItemProperties} properties.
|
||
* Note that more generic extended item functions should be confined to `ExtendedItem.js`.
|
||
*/
|
||
|
||
/**
|
||
* A Map that maps input element IDs to their original value is defined in, _.e.g_, {@link PropertyOpacityLoad}.
|
||
* Used as fallback in case an invalid opacity value is encountered when exiting.
|
||
* @type {Map<string, any>}
|
||
*/
|
||
const PropertyOriginalValue = new Map([]);
|
||
|
||
/**
|
||
* Construct an item-specific ID for a properties input element (_e.g._ an opacity slider).
|
||
* @param {string} Name - The name of the input element
|
||
* @param {Item} Item - The item for whom the ID should be constructed; defaults to {@link DialogFocusItem}
|
||
* @returns {string} - The ID of the property
|
||
*/
|
||
function PropertyGetID(Name, Item=DialogFocusItem) {
|
||
return `${Item.Asset.Group.Name}${Item.Asset.Name}${Name}`;
|
||
}
|
||
|
||
/**
|
||
* Throttled callback for opacity slider changes
|
||
* @param {Character} C - The character being modified
|
||
* @param {Item} item - The item being modified
|
||
* @param {number} Opacity - The new opacity to set on the item
|
||
* @returns {void} - Nothing
|
||
*/
|
||
const PropertyOpacityChange = CommonLimitFunction((C, Item, Opacity) => {
|
||
Item.Property.Opacity = Opacity;
|
||
CharacterLoadCanvas(C);
|
||
});
|
||
|
||
/** @type {ExtendedItemScriptHookCallbacks.Init<ExtendedItemData<any>>} */
|
||
function PropertyOpacityInit({ asset }, originalFunction, C, item, push=true, refresh=true) {
|
||
if (!CommonIsObject(item.Property)) {
|
||
item.Property = {};
|
||
}
|
||
|
||
// Abort prematurely if all opacity values are within bounds; no need for any refreshes or pushes as nothing was changed
|
||
const opacity = item.Property.Opacity;
|
||
if (CommonIsFinite(opacity, asset.MinOpacity, asset.MaxOpacity)) {
|
||
return originalFunction?.(C, item, push, refresh) ?? false;
|
||
} else if (
|
||
CommonIsArray(opacity)
|
||
&& opacity.length === asset.Layer.length
|
||
&& opacity.every((o, i) => CommonIsFinite(o, asset.Layer[i].MinOpacity, asset.Layer[i].MaxOpacity))
|
||
) {
|
||
return originalFunction?.(C, item, push, refresh) ?? false;
|
||
}
|
||
|
||
// Illegal or uninitialized opacity value; restore a valid default and perform the whole push + refresh dance
|
||
item.Property.Opacity = asset.Opacity;
|
||
|
||
if (originalFunction) originalFunction(C, item, false, false);
|
||
if (refresh) CharacterRefresh(C, push, false);
|
||
if (push) ChatRoomCharacterItemUpdate(C, asset.Group.Name);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Load function for items with opacity sliders. Constructs the opacity slider.
|
||
* @param {ExtendedItemData<any>} Data - The items extended item data
|
||
* @param {() => void} OriginalFunction - The function that is normally called when an archetypical item reaches this point (if any).
|
||
* @param {ThumbIcon} thumbIcon The icon to use for the range input's "thumb" (handle).
|
||
* @returns {HTMLInputElement} - The new or pre-existing range input element of the opacity slider
|
||
* @satisfies {ExtendedItemScriptHookCallbacks.Load<any>}
|
||
*/
|
||
function PropertyOpacityLoad({ asset, dialogPrefix }, OriginalFunction, thumbIcon="blindfold") {
|
||
OriginalFunction();
|
||
const ID = PropertyGetID("Opacity");
|
||
|
||
if (!PropertyOriginalValue.has(ID)) {
|
||
PropertyOriginalValue.set(ID, DialogFocusItem.Property.Opacity);
|
||
}
|
||
|
||
const opacity = CommonIsNumeric(DialogFocusItem.Property.Opacity) ? DialogFocusItem.Property.Opacity : 1;
|
||
const opacitySlider = ElementCreateRangeInput(
|
||
ID,
|
||
opacity,
|
||
asset.MinOpacity,
|
||
asset.MaxOpacity,
|
||
0.01,
|
||
thumbIcon,
|
||
);
|
||
|
||
if (opacitySlider) {
|
||
const C = CharacterGetCurrent();
|
||
opacitySlider.addEventListener("input", (e) => PropertyOpacityChange(C, DialogFocusItem, Number(/** @type {HTMLInputElement} */ (e.target).value)));
|
||
return opacitySlider;
|
||
} else {
|
||
return /** @type {HTMLInputElement} */(document.getElementById(ID));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Draw function for items with opacity sliders. Draws the opacity slider and further opacity-related information.
|
||
* @param {ExtendedItemData<any>} Data - The items extended item data
|
||
* @param {() => void} OriginalFunction - The function that is normally called when an archetypical item reaches this point (if any).
|
||
* @param {number} XOffset - An offset for all text and slider X coordinates
|
||
* @param {number} YOffset - An offset for all text and slider Y coordinates
|
||
* @param {string} LabelKeyword - The keyword of the opacity label
|
||
* @returns {void} Nothing
|
||
* @satisfies {ExtendedItemScriptHookCallbacks.Draw<any>}
|
||
*/
|
||
function PropertyOpacityDraw(Data, OriginalFunction, XOffset=0, YOffset=0, LabelKeyword="OpacityLabel") {
|
||
OriginalFunction();
|
||
const ID = PropertyGetID("Opacity");
|
||
|
||
MainCanvas.textAlign = "right";
|
||
DrawTextFit(
|
||
InterfaceTextGet(LabelKeyword), 1375 + XOffset, 450 + YOffset,
|
||
400, "#FFFFFF", "#000",
|
||
);
|
||
ElementPosition(ID, 1625 + XOffset, 450 + YOffset, 400);
|
||
const opacity = CommonIsNumeric(DialogFocusItem.Property.Opacity) ? DialogFocusItem.Property.Opacity : 1;
|
||
DrawTextFit(
|
||
`${Math.round(opacity * 100)}%`, 1925 + XOffset, 450 + YOffset,
|
||
400, "#FFFFFF", "#000",
|
||
);
|
||
MainCanvas.textAlign = "center";
|
||
}
|
||
|
||
/**
|
||
* Exit function for items with opacity sliders. Updates the items opacity, deletes the slider and (optionally) refreshes the character and item.
|
||
* @param {ExtendedItemData<any>} Data - The items extended item data
|
||
* @param {null | (() => void)} OriginalFunction - The function that is normally called when an archetypical item reaches this point (if any).
|
||
* @param {boolean} Refresh - Whether character parameters and the respective item should be refreshed or not
|
||
* @returns {boolean} Whether the opacity was updated or not
|
||
* @satisfies {ExtendedItemScriptHookCallbacks.Exit<any>}
|
||
*/
|
||
function PropertyOpacityExit({ asset }, OriginalFunction, Refresh=true) {
|
||
if (OriginalFunction != null) {
|
||
OriginalFunction();
|
||
}
|
||
|
||
const ID = PropertyGetID("Opacity");
|
||
const C = CharacterGetCurrent();
|
||
const Opacity = Number(ElementValue(ID));
|
||
|
||
// Restore the original opacity if the new opacity is invalid
|
||
if (!(Opacity <= asset.MaxOpacity && Opacity >= asset.MinOpacity)) {
|
||
DialogFocusItem.Property.Opacity = PropertyOriginalValue.get(ID);
|
||
ElementRemove(ID);
|
||
PropertyOriginalValue.delete(ID);
|
||
return false;
|
||
}
|
||
|
||
// Remove the element after calling `CharacterRefresh`
|
||
// The latter will call `Load`, which would otherwise restore the slider again
|
||
if (Refresh) {
|
||
CharacterRefresh(C, true, false);
|
||
ChatRoomCharacterItemUpdate(C, asset.Group.Name);
|
||
}
|
||
ElementRemove(ID);
|
||
PropertyOriginalValue.delete(ID);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Helper fuction for publishing shock-related actions.
|
||
* @param {Character} C - The shocked character; defaults to the {@link CharacterGetCurrent} output
|
||
* @param {Item} Item - The shocking item; defaults to {@link DialogFocusItem}
|
||
* @param {boolean} Automatic - Whether the shock was triggered automatically or otherwise manually
|
||
*/
|
||
function PropertyShockPublishAction(C=null, Item=DialogFocusItem, Automatic=false) {
|
||
if (C == null) {
|
||
C = CharacterGetCurrent();
|
||
}
|
||
if (Item == null) {
|
||
return;
|
||
}
|
||
|
||
// Get item-specific properties and choose a suitable default if absent
|
||
const ShockLevel = (Item.Property.ShockLevel != null) ? Item.Property.ShockLevel : 1;
|
||
const ShowText = (Item.Property.ShowText != null) ? Item.Property.ShowText : true;
|
||
if (Item.Property.TriggerCount != null) {
|
||
Item.Property.TriggerCount++;
|
||
}
|
||
|
||
if (C.ID === Player.ID) {
|
||
// The Player shocks herself
|
||
ActivityArousalItem(C, C, Item.Asset);
|
||
}
|
||
InventoryShockExpression(C);
|
||
|
||
const Dictionary = new DictionaryBuilder()
|
||
.destinationCharacterName(C)
|
||
.asset(Item.Asset, "AssetName", Item.Craft && Item.Craft.Name)
|
||
.shockIntensity(ShockLevel * 1.5)
|
||
.focusGroup(Item.Asset.Group.Name)
|
||
.if(Automatic)
|
||
.markAutomatic()
|
||
.endif()
|
||
.build();
|
||
|
||
const ActionTag = `TriggerShock${ShockLevel}`;
|
||
|
||
// Manually play audio and flash the screen when not in a chatroom
|
||
if (CurrentScreen !== "ChatRoom") {
|
||
AudioPlaySoundEffect("Shocks", 3 + (3 * ShockLevel));
|
||
if (C.ID === Player.ID) {
|
||
const duration = (Math.random() + ShockLevel * 1.5) * 500;
|
||
DrawFlashScreen("#FFFFFF", duration, 500);
|
||
}
|
||
}
|
||
|
||
// Publish the action, be it either quietly or not
|
||
if (ShowText && CurrentScreen === "ChatRoom") {
|
||
ChatRoomPublishCustomAction(ActionTag, false, Dictionary);
|
||
} else if (CurrentScreen === "ChatRoom") {
|
||
ChatRoomMessage({ Content: ActionTag, Type: "Action", Sender: Player.MemberNumber, Dictionary: Dictionary });
|
||
}
|
||
|
||
// Exit the dialog menu when triggering a manual shock
|
||
if (!Automatic) {
|
||
ExtendedItemCustomExit(ActionTag);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A set of group names whose auto-punishment has successfully been handled by {@link PropertyAutoPunishDetectSpeech}.
|
||
* If a group name is absent from the set then it's eligible for action-based punishment triggers.
|
||
* The initial set is populated by {@link AssetLoadAll} after all asset groups are defined.
|
||
* @type {Set<AssetGroupName>}
|
||
*/
|
||
let PropertyAutoPunishHandled = new Set();
|
||
|
||
/**
|
||
* A set with the names of all activities as performed by the player.
|
||
* Functions as a cache for {@link PropertyPunishActivityCheck} and can be automatically emptied out by the latter.
|
||
* @type {Set<ActivityName>}
|
||
*/
|
||
let PropertyPunishActivityCache = new Set();
|
||
|
||
/**
|
||
* A list of keywords that can trigger automatic punishment when included in `/me`- or `*`-based messages
|
||
* @type {readonly string[]}
|
||
*/
|
||
const PropertyAutoPunishKeywords = [
|
||
"moan",
|
||
"whimper",
|
||
"shout",
|
||
"scream",
|
||
"whine",
|
||
"growl",
|
||
"laugh",
|
||
"giggle",
|
||
"mutter",
|
||
"stutter",
|
||
"stammer",
|
||
"grunt",
|
||
"hiss",
|
||
"screech",
|
||
"bark",
|
||
"mumble",
|
||
];
|
||
|
||
/**
|
||
* Check if a given message warants automatic punishment given the provided sensitivety level
|
||
* @param {0 | 1 | 2 | 3} Sensitivity - The auto-punishment sensitivety
|
||
* @param {string} msg - The to-be checked message
|
||
* @returns {boolean} Whether the passed message should trigger automatic speech-based punishment
|
||
*/
|
||
function PropertyAutoPunishParseMessage(Sensitivity, msg) {
|
||
// Remove the OOC component(s) from the message, as those are never punishable
|
||
const oocRanges = SpeechGetOOCRanges(msg).reverse();
|
||
const arrayMsg = Array.from(msg);
|
||
oocRanges.forEach(({ start, length }) => arrayMsg.splice(start, length));
|
||
msg = arrayMsg.join("");
|
||
|
||
// Conditions that are always punishable
|
||
const PunishableSpeech = (
|
||
msg.includes('!')
|
||
|| msg.includes('!')
|
||
|| (msg === msg.toUpperCase() && msg !== msg.toLowerCase())
|
||
);
|
||
|
||
// Check for sensitivity-specific conditions
|
||
let PunishableKeywords = false;
|
||
switch (Sensitivity) {
|
||
case 1:
|
||
return (
|
||
!msg.startsWith("*")
|
||
&& !msg.startsWith("/")
|
||
&& (msg.replace(/[^\p{P} ~+=^$|\\<>`]+/ug, '') !== msg && PunishableSpeech)
|
||
);
|
||
case 2:
|
||
return (
|
||
!msg.startsWith("*")
|
||
&& !msg.startsWith("/")
|
||
&& (
|
||
msg.length > 25
|
||
|| (msg.replace(/[^\p{P} ~+=^$|\\<>`]+/ug, '') !== msg && PunishableSpeech)
|
||
)
|
||
);
|
||
case 3:
|
||
PunishableKeywords = PropertyAutoPunishKeywords.some((k) => msg.includes(k));
|
||
if (PunishableKeywords && (msg.startsWith("/me") || msg.startsWith("*"))) {
|
||
return true;
|
||
}
|
||
|
||
return (
|
||
!msg.startsWith("*")
|
||
&& !msg.startsWith("/")
|
||
&& (msg.replace(/[^\p{P} ~+=^$|\\<>`]+/ug, '') !== msg || PunishableSpeech)
|
||
);
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check whether the last uttered message should trigger automatic punishment from the provided item
|
||
* @param {Item} Item - The item in question
|
||
* @param {number | null} LastMessageLen - The length of {@link ChatRoomLastMessage} prior to the last message (if applicable)
|
||
* @returns {boolean} Whether the last message should trigger automatic speech-based punishment
|
||
*/
|
||
function PropertyAutoPunishDetectSpeech(Item, LastMessageLen=null) {
|
||
const GroupName = Item.Asset.Group.Name;
|
||
const GagAction = !PropertyAutoPunishHandled.has(GroupName);
|
||
PropertyAutoPunishHandled.add(GroupName);
|
||
|
||
// Abort the item does not have `AutoPunish` set
|
||
if (!Item.Property || !Item.Property.AutoPunish) {
|
||
return false;
|
||
}
|
||
|
||
// Gag actions at maximum `AutoPunish` values always inflate
|
||
if (Item.Property.AutoPunish === 3 && GagAction) {
|
||
return true;
|
||
}
|
||
|
||
// Abort on whispers or if no new messages have been submitted
|
||
if (ChatRoomTargetMemberNumber >= 0 || !ChatRoomLastMessage || ChatRoomLastMessage.length === LastMessageLen) {
|
||
return false;
|
||
}
|
||
|
||
const msg = ChatRoomLastMessage[ChatRoomLastMessage.length - 1];
|
||
return PropertyAutoPunishParseMessage(Item.Property.AutoPunish, msg);
|
||
}
|
||
|
||
/**
|
||
* Check if the player character has performed one or more of the passed activities ever since the last {@link PropertyPunishActivityNames} refresh.
|
||
* @param {null | ActivityName | readonly ActivityName[]} name - The name(s) of the activity to check. If `null`, check if any activity at all is present
|
||
* @param {boolean} clearCache - Whether to automatically remove `name` from the {@link PropertyPunishActivityNames} cache.
|
||
* `name == null` implies that all entries should be removed.
|
||
* @returns {boolean}
|
||
*/
|
||
function PropertyPunishActivityCheck(name=null, clearCache=true) {
|
||
/** @type {boolean} */
|
||
let punish;
|
||
if (typeof name === "string") {
|
||
punish = PropertyPunishActivityCache.has(name);
|
||
if (clearCache) { PropertyPunishActivityCache.delete(name); }
|
||
} else if (CommonIsArray(name)) {
|
||
punish = name.some(PropertyPunishActivityCache.has);
|
||
if (clearCache) { name.forEach(PropertyPunishActivityCache.delete); }
|
||
} else {
|
||
punish = PropertyPunishActivityCache.size > 0;
|
||
if (clearCache) { PropertyPunishActivityCache.clear(); }
|
||
}
|
||
return punish;
|
||
}
|
||
|
||
/**
|
||
* Merge all passed item properties into the passed output, merging (and shallow copying) arrays if necessary.
|
||
* @param {ItemProperties} output - The to be updated properties
|
||
* @param {readonly ItemProperties[]} args - The additional item properties to be merged into the output
|
||
* @returns {ItemProperties} - The passed output modified inplace
|
||
*/
|
||
function PropertyUnion(output, ...args) {
|
||
for (const property of args) {
|
||
for (const [key, value] of Object.entries(property)) {
|
||
switch (key) {
|
||
case "Tint": {
|
||
if (!Array.isArray(value)) {
|
||
break;
|
||
}
|
||
const previousValue = Array.isArray(output[key]) ? [...output[key]] : [];
|
||
output[key] = [
|
||
...previousValue,
|
||
...value.filter(i => !previousValue.some(j => CommonObjectEqual(i, j))),
|
||
];
|
||
break;
|
||
}
|
||
case "HeightModifier":
|
||
case "Difficulty": {
|
||
output[key] = (output[key] || 0) + (value || 0);
|
||
break;
|
||
}
|
||
case "TypeRecord":
|
||
output[key] = {
|
||
...(output[key] || {}),
|
||
...value,
|
||
};
|
||
break;
|
||
default: {
|
||
if (Array.isArray(value)) {
|
||
const previousValue = Array.isArray(output[key]) ? [...output[key]] : [];
|
||
output[key] = [
|
||
...previousValue,
|
||
...value.filter(i => !previousValue.includes(i)),
|
||
];
|
||
} else {
|
||
output[key] = value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return output;
|
||
}
|
||
|
||
/**
|
||
* Remove all passed item properties from the passed output, removing (and shallow copying) array entries if necessary.
|
||
* @param {ItemProperties} output - The to-be updated properties
|
||
* @param {readonly ItemProperties[]} args - The additional item properties to be removed from the output
|
||
* @returns {ItemProperties} - The passed output modified inplace
|
||
*/
|
||
function PropertyDifference(output, ...args) {
|
||
for (const property of args) {
|
||
for (const [key, value] of Object.entries(property)) {
|
||
switch (key) {
|
||
case "Tint": {
|
||
if (!Array.isArray(value)) {
|
||
break;
|
||
}
|
||
const previousValue = Array.isArray(output[key]) ? output[key] : [];
|
||
output[key] = previousValue.filter(i => !value.some(j => CommonObjectEqual(i, j)));
|
||
break;
|
||
}
|
||
case "HeightModifier":
|
||
case "Difficulty": {
|
||
output[key] = (output[key] || 0) - (value || 0);
|
||
break;
|
||
}
|
||
case "TypeRecord":
|
||
output[key] = CommonOmit(output[key] || {}, Object.keys(value));
|
||
break;
|
||
default: {
|
||
if (Array.isArray(value)) {
|
||
const previousValue = Array.isArray(output[key]) ? output[key] : [];
|
||
output[key] = previousValue.filter(i => !value.includes(i));
|
||
if (output[key].length > 0) {
|
||
break;
|
||
}
|
||
}
|
||
delete output[key];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return output;
|
||
}
|
||
|
||
/**
|
||
* Convert the passed type record into a list of stringified key/value pairs.
|
||
* @param {TypeRecord} typeRecord
|
||
* @returns {string[]}
|
||
*/
|
||
function PropertyTypeRecordToStrings(typeRecord) {
|
||
return Object.entries(typeRecord).map(([i, j]) => `${i}${j}`);
|
||
}
|