Roll on the sprite to use for an expression
| @@ -360,7 +360,7 @@ async function setImage(img, path) { | ||
| 360 | 360 | return new Promise(resolve => { |
| 361 | 361 | const prevExpressionSrc = img.attr('src'); |
| 362 | 362 | const expressionClone = img.clone(); |
| 363 | 363 | const originalId = img.attrdata('idfilename'); |
| 364 | 364 | |
| 365 | 365 | //only swap expressions when necessary |
| 366 | 366 | if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { |
| @@ -368,7 +368,7 @@ async function setImage(img, path) { | ||
| 368 | 368 | expressionClone.addClass('expression-clone'); |
| 369 | 369 | //make invisible and remove id to prevent double ids |
| 370 | 370 | //must be made invisible to start because they share the same Z-index |
| 371 | 371 | expressionClone.attrdata('idfilename', '').css({ opacity: 0 }); |
| 372 | 372 | //add new sprite path to clone src |
| 373 | 373 | expressionClone.attr('src', path); |
| 374 | 374 | //add invisible clone to html |
| @@ -404,7 +404,7 @@ async function setImage(img, path) { | ||
| 404 | 404 | //remove old expression |
| 405 | 405 | img.remove(); |
| 406 | 406 | //replace ID so it becomes the new 'original' expression for next change |
| 407 | 407 | expressionClone.attrdata('idfilename', originalId); |
| 408 | 408 | expressionClone.removeClass('expression-animating'); |
| 409 | 409 | |
| 410 | 410 | // Reset the expression holder min height and width |
| @@ -1311,9 +1311,9 @@ function removeExpression() { | ||
| 1311 | 1311 | /** |
| 1312 | 1312 | * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw. |
| 1313 | 1313 | * @param {string} character - The character to validate |
| 1314 | 1314 | * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before |
| 1315 | 1315 | */ |
| 1316 | 1316 | async function validateImages(character, forceRedrawCached = false) { |
| 1317 | 1317 | if (!character) { |
| 1318 | 1318 | return; |
| 1319 | 1319 | } |
| @@ -1493,6 +1493,13 @@ async function renderFallbackExpressionPicker() { | ||
| 1493 | 1493 | } |
| 1494 | 1494 | } |
| 1495 | 1495 | |
| 1496 | +/** | |
| 1497 | + * Retrieves a unique list of cached expressions. | |
| 1498 | + * Combines the default expressions list with custom user-defined expressions. | |
| 1499 | + * | |
| 1500 | + * @returns {string[]} An array of unique expression labels | |
| 1501 | + */ | |
| 1502 | + | |
| 1496 | 1503 | function getCachedExpressions() { |
| 1497 | 1504 | if (!Array.isArray(expressionsList)) { |
| 1498 | 1505 | return []; |
| @@ -1558,7 +1565,14 @@ export async function getExpressionsList() { | ||
| 1558 | 1565 | return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); |
| 1559 | 1566 | } |
| 1560 | 1567 | |
| 1561 | -async function setExpression(character, expression, force) { | |
| 1568 | +/** | |
| 1569 | + * Set the expression of a character. | |
| 1570 | + * @param {string} character - The name of the character | |
| 1571 | + * @param {string} expression - The expression to set | |
| 1572 | + * @param {boolean} [force=false] - Whether to force the expression change even if Visual Novel mode is on. | |
| 1573 | + * @returns {Promise<void>} A promise that resolves when the expression has been set. | |
| 1574 | + */ | |
| 1575 | +async function setExpression(character, expression, force = false) { | |
| 1562 | 1576 | if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) { |
| 1563 | 1577 | console.debug('entered setExpressions'); |
| 1564 | 1578 | await validateImages(character); |
| @@ -1566,11 +1580,23 @@ async function setExpression(character, expression, force) { | ||
| 1566 | 1580 | const prevExpressionSrc = img.attr('src'); |
| 1567 | 1581 | const expressionClone = img.clone(); |
| 1568 | 1582 | |
| 1583 | + /** @type {Expression} */ | |
| 1569 | 1584 | const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression)); |
| 1570 | 1585 | console.debug('checking for expression images to show..'); |
| 1571 | 1586 | if (sprite) { |
| 1572 | 1587 | console.debug('setting expression from character images folder'); |
| 1573 | 1588 | |
| 1589 | + let spriteFile = sprite.files[0]; | |
| 1590 | + | |
| 1591 | + // Calculate next expression | |
| 1592 | + if (sprite.files.length > 1) { | |
| 1593 | + let possibleFiles = sprite.files; | |
| 1594 | + if (extension_settings.expressions_reroll_if_same) { | |
| 1595 | + possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc); | |
| 1596 | + } | |
| 1597 | + spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)]; | |
| 1598 | + } | |
| 1599 | + | |
| 1574 | 1600 | if (force && isVisualNovelMode()) { |
| 1575 | 1601 | const context = getContext(); |
| 1576 | 1602 | const group = context.groups.find(x => x.id === context.groupId); |
| @@ -1583,13 +1609,13 @@ async function setExpression(character, expression, force) { | ||
| 1583 | 1609 | } |
| 1584 | 1610 | |
| 1585 | 1611 | if (groupMember.name == character) { |
| 1586 | 1612 | await setImage($(`.expression-holder[data-avatar="${member}"] img`), spritespriteFile.pathimageSrc); |
| 1587 | 1613 | return; |
| 1588 | 1614 | } |
| 1589 | 1615 | } |
| 1590 | 1616 | } |
| 1591 | 1617 | //only swap expressions when necessary |
| 1592 | 1618 | if (prevExpressionSrc !== spritespriteFile.pathimageSrc |
| 1593 | 1619 | && !img.hasClass('expression-animating')) { |
| 1594 | 1620 | //clone expression |
| 1595 | 1621 | expressionClone.addClass('expression-clone'); |
| @@ -1597,7 +1623,7 @@ async function setExpression(character, expression, force) { | ||
| 1597 | 1623 | //must be made invisible to start because they share the same Z-index |
| 1598 | 1624 | expressionClone.attr('id', '').css({ opacity: 0 }); |
| 1599 | 1625 | //add new sprite path to clone src |
| 1600 | 1626 | expressionClone.attr('src', spritespriteFile.pathimageSrc); |
| 1601 | 1627 | //add invisible clone to html |
| 1602 | 1628 | expressionClone.appendTo($('#expression-holder')); |
| 1603 | 1629 | |
| @@ -1645,7 +1671,7 @@ async function setExpression(character, expression, force) { | ||
| 1645 | 1671 | expressionClone.removeClass('default'); |
| 1646 | 1672 | expressionClone.off('error'); |
| 1647 | 1673 | expressionClone.on('error', function () { |
| 1648 | 1674 | console.debug('Expression image error', spritespriteFile.pathimageSrc); |
| 1649 | 1675 | $(this).attr('src', ''); |
| 1650 | 1676 | $(this).off('error'); |
| 1651 | 1677 | if (force && extension_settings.expressions.showDefault) { |
| @@ -1706,7 +1732,7 @@ async function setExpression(character, expression, force) { | ||
| 1706 | 1732 | } |
| 1707 | 1733 | |
| 1708 | 1734 | function onClickExpressionImage() { |
| 1709 | 1735 | const expression = $(this).attrdata('idexpression'); |
| 1710 | 1736 | setSpriteSlashCommand({}, expression); |
| 1711 | 1737 | } |
| 1712 | 1738 | |
| @@ -1830,7 +1856,7 @@ async function onClickExpressionUpload(event) { | ||
| 1830 | 1856 | // Prevents the expression from being set |
| 1831 | 1857 | event.stopPropagation(); |
| 1832 | 1858 | |
| 1833 | 1859 | const idexpression = $(this).closest('.expression_list_item').attrdata('idexpression'); |
| 1834 | 1860 | const name = $('#image_list').data('name'); |
| 1835 | 1861 | |
| 1836 | 1862 | const handleExpressionUploadChange = async (e) => { |
| @@ -1840,9 +1866,20 @@ async function onClickExpressionUpload(event) { | ||
| 1840 | 1866 | return; |
| 1841 | 1867 | } |
| 1842 | 1868 | |
| 1869 | + // // If extension_settings.expressions.allowMultiple is false and there's already a main image, ask user: | |
| 1870 | + // let hasMainImage = true; // Check from your item data | |
| 1871 | + // if (!extension_settings.expressions.allowMultiple && hasMainImage) { | |
| 1872 | + // let userChoice = await callPopup('<h3>Replace existing main image?</h3><p>Press Ok to replace, Cancel to abort.</p>', 'confirm'); | |
| 1873 | + // if (!userChoice) { | |
| 1874 | + // return; | |
| 1875 | + // } | |
| 1876 | + // // If user chooses replace, remove the old file, then proceed | |
| 1877 | + // // ...existing code to remove old file... | |
| 1878 | + // } | |
| 1879 | + | |
| 1843 | 1880 | const formData = new FormData(); |
| 1844 | 1881 | formData.append('name', name); |
| 1845 | 1882 | formData.append('label', idexpression); |
| 1846 | 1883 | formData.append('avatar', file); |
| 1847 | 1884 | |
| 1848 | 1885 | await handleFileUpload('/api/sprites/upload', formData); |
| @@ -1851,7 +1888,7 @@ async function onClickExpressionUpload(event) { | ||
| 1851 | 1888 | e.target.form.reset(); |
| 1852 | 1889 | |
| 1853 | 1890 | // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char. |
| 1854 | 1891 | if (idexpression === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) { |
| 1855 | 1892 | await loadTalkingHead(); |
| 1856 | 1893 | } |
| 1857 | 1894 | }; |
| @@ -1987,14 +2024,14 @@ async function onClickExpressionDelete(event) { | ||
| 1987 | 2024 | return; |
| 1988 | 2025 | } |
| 1989 | 2026 | |
| 1990 | 2027 | const idexpression = $(this).closest('.expression_list_item').attrdata('idexpression'); |
| 1991 | 2028 | const name = $('#image_list').data('name'); |
| 1992 | 2029 | |
| 1993 | 2030 | try { |
| 1994 | 2031 | await fetch('/api/sprites/delete', { |
| 1995 | 2032 | method: 'POST', |
| 1996 | 2033 | headers: getRequestHeaders(), |
| 1997 | 2034 | body: JSON.stringify({ name, label: idexpression }), |
| 1998 | 2035 | }); |
| 1999 | 2036 | } catch (error) { |
| 2000 | 2037 | toastr.error('Failed to delete image. Try again later.'); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | {{#each images}} |
| 2 | 2 | <div class="expression_list_item" data-epressionexpression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}"> |
| 3 | 3 | <div class="expression_list_buttons"> |
| 4 | 4 | <div class="menu_button expression_list_upload" title="Upload image"> |
| 5 | 5 | <i class="fa-solid fa-upload"></i> |