diff --git a/docs/configure-cms.md b/docs/configure-cms.md index e524d643..e2afe6b1 100644 --- a/docs/configure-cms.md +++ b/docs/configure-cms.md @@ -54,7 +54,7 @@ To add the shop in your website selling items and services you have to:
4. **(optional)** install the plugin `myCred` to add **"virtual points"** in your website, this will allow you to define a relation between money and your virtual point, so any user can buy items in the shopt through virtual points and buy virtual points with €/$ -![Shop](shop.png) +![Shop](images/shop.png) Besides items you can also sell: @@ -72,6 +72,24 @@ Besides items you can also sell: **Note:** if you want to sell a cumulative item you can use the `SKU itemsend_ITEM-ID_stack`. +### 3D Model Viewer + +In any product it is possible to enable/disable the 3D viewer via settings on Inventory -> 3D Viewer. + +![3d viewer](images/3d-viewer-option.png) + +It currently suports: +- mounts/pets/companion under itemsend SKU +- transmog items under transmog-item SKU + +Examples: + +3d viewer mount + +3d viewer pet + +3d viewer pet + ## Troubleshooting For everything ask help on [Discord](https://discord.gg/gkt4y2x) in the channel `#acore-cms` (section `TOOLS`), you can also tag @Helias for any issue about this CMS. diff --git a/docs/images/3d-viewer-example-mount.png b/docs/images/3d-viewer-example-mount.png new file mode 100644 index 00000000..d0c02373 Binary files /dev/null and b/docs/images/3d-viewer-example-mount.png differ diff --git a/docs/images/3d-viewer-example-pet.png b/docs/images/3d-viewer-example-pet.png new file mode 100644 index 00000000..a490c6da Binary files /dev/null and b/docs/images/3d-viewer-example-pet.png differ diff --git a/docs/images/3d-viewer-example-transmog.png b/docs/images/3d-viewer-example-transmog.png new file mode 100644 index 00000000..69d292d9 Binary files /dev/null and b/docs/images/3d-viewer-example-transmog.png differ diff --git a/docs/images/3d-viewer-option.png b/docs/images/3d-viewer-option.png new file mode 100644 index 00000000..223576d1 Binary files /dev/null and b/docs/images/3d-viewer-option.png differ diff --git a/docs/shop.png b/docs/images/shop.png similarity index 100% rename from docs/shop.png rename to docs/images/shop.png diff --git a/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php b/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php index c72d57d1..81ffb692 100644 --- a/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php +++ b/src/acore-wp-plugin/src/Hooks/WooCommerce/FieldElements.php @@ -114,4 +114,62 @@ public static function destAccount(): void { ID, '_custom_3d_checkbox', true); + + if ($custom_3d_checkbox !== 'yes') { + return; + } + + ?> + + + + + itemId); + $current_user = wp_get_current_user(); if ($current_user) { diff --git a/src/acore-wp-plugin/src/Hooks/WooCommerce/Products.php b/src/acore-wp-plugin/src/Hooks/WooCommerce/Products.php new file mode 100644 index 00000000..e0d8b7d3 --- /dev/null +++ b/src/acore-wp-plugin/src/Hooks/WooCommerce/Products.php @@ -0,0 +1,19 @@ + '_custom_3d_checkbox', + 'label' => __('3D Viewer', 'woocommerce'), + 'description' => __('Check this box to enable the 3D viewer for this product.', 'woocommerce'), + )); +} +add_action('woocommerce_product_options_inventory_product_data', 'add_custom_3d_checkbox_field'); + + +function save_3d_checkbox_field($post_id) { + $custom_checkbox = isset($_POST['_custom_3d_checkbox']) ? 'yes' : 'no'; + update_post_meta($post_id, '_custom_3d_checkbox', $custom_checkbox); +} +add_action('woocommerce_process_product_meta', 'save_3d_checkbox_field'); +?> diff --git a/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php b/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php index 9206bfde..61966bfb 100755 --- a/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php +++ b/src/acore-wp-plugin/src/Hooks/WooCommerce/TransmogItemSend.php @@ -34,6 +34,8 @@ public static function before_add_to_cart_button() { return; } + FieldElements::get3dViewer($itemId); + $current_user = wp_get_current_user(); if ($current_user) { diff --git a/src/acore-wp-plugin/src/boot.php b/src/acore-wp-plugin/src/boot.php index 2e401b4d..ea262eec 100644 --- a/src/acore-wp-plugin/src/boot.php +++ b/src/acore-wp-plugin/src/boot.php @@ -20,3 +20,4 @@ require_once ACORE_PATH_PLG . 'src/Hooks/User/Include.php'; require_once ACORE_PATH_PLG . 'src/Hooks/WooCommerce/WooCommerce.php'; +require_once ACORE_PATH_PLG . 'src/Hooks/WooCommerce/Products.php'; diff --git a/src/acore-wp-plugin/web/libraries/wow-model-viewer/character_modeling.js b/src/acore-wp-plugin/web/libraries/wow-model-viewer/character_modeling.js new file mode 100644 index 00000000..8573574a --- /dev/null +++ b/src/acore-wp-plugin/web/libraries/wow-model-viewer/character_modeling.js @@ -0,0 +1,286 @@ +/* Author: https://github.com/Miorey/wow-model-viewer */ + +import "./setup.js"; + +const NOT_DISPLAYED_SLOTS = [ + 2, // neck + 11, // finger1 + 12, // finger1 + 13, // trinket1 + 14, // trinket2 +]; + +const modelingType = { + ARMOR: 128, + CHARACTER: 16, + COLLECTION: 1024, + HELM: 2, + HUMANOIDNPC: 32, + ITEM: 1, + ITEMVISUAL: 512, + NPC: 8, + OBJECT: 64, + PATH: 256, + SHOULDER: 4, +}; + +const characterPart = () => { + const ret = { + Face: `face`, + "Skin Color": `skin`, + "Hair Style": `hairStyle`, + "Hair Color": `hairColor`, + "Facial Hair": `facialStyle`, + Mustache: `facialStyle`, + Beard: `facialStyle`, + Sideburns: `facialStyle`, + "Face Shape": `facialStyle`, + Eyebrow: `facialStyle`, + "Jaw Features": undefined, + "Face Features": undefined, + "Skin Type": undefined, + Ears: window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `ears`, + "Fur Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `furColor`, + Snout: `snout`, + Blindfold: undefined, + Tattoo: undefined, + "Eye Color": undefined, + "Tattoo Color": undefined, + Armbands: undefined, + "Jewelry Color": undefined, + Bracelets: undefined, + Necklace: undefined, + Earring: undefined, + "Primary Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API + ? undefined + : `primaryColor`, + "Secondary Color Strength": window.WOTLK_TO_RETAIL_DISPLAY_ID_API + ? undefined + : `secondaryColorStrength`, + "Secondary Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API + ? undefined + : `secondaryColor`, + "Horn Color": window.WOTLK_TO_RETAIL_DISPLAY_ID_API + ? undefined + : `hornColor`, + Horns: window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `horns`, + "Body Size": window.WOTLK_TO_RETAIL_DISPLAY_ID_API ? undefined : `bodySize`, + }; + // console.log(ret); + return ret; +}; + +function optionalChaining(choice) { + //todo replace by `part.Choices[character[CHARACTER_PART[prop]]]?.Id` when it works on almost all frameworks + return choice ? choice.Id : undefined; +} + +/** + * + * @param {Object} character - The character object. + * @param {number} character.face - Description for face. + * @param {number} character.facialStyle - Description for facialStyle. + * @param {number} character.gender - Description for gender. + * @param {number} character.hairColor - Description for hairColor. + * @param {number} character.hairStyle - Description for hairStyle. + * @param {Array>} character.items - Description for items. (Optional) + * @param {number} character.race - Description for race. + * @param {number} character.skin - Description for skin. + * @param {Object} fullOptions - Zaming API character options payload. + * @return {[]} + */ +function getCharacterOptions(character, fullOptions) { + const options = fullOptions.Options; + const missingChoice = []; + const ret = []; + for (const prop in characterPart()) { + const part = options.find((e) => e.Name === prop); + + if (!part) { + continue; + } + + const newOption = { + optionId: part.Id, + choiceId: characterPart()[prop] + ? optionalChaining(part.Choices[character[characterPart()[prop]]]) + : part.Choices[0].Id, + }; + if (newOption.choiceId === undefined) { + missingChoice.push(characterPart()[prop]); + } + ret.push(newOption); + } + console.warn( + `In character: `, + character, + `the following options are missing`, + missingChoice + ); + + return ret; +} + +/** + * This function return the design choices for a character this does not work for NPC / Creature / Items + * @param {Object} model - The model object to generate options from. + * @param {{}} fullOptions - The type of the model. + * @returns {{models: {id: string, type: number}, charCustomization: {options: []}, items: (*|*[])}|{models: {id, type}} + */ +function optionsFromModel(model, fullOptions) { + const { race, gender } = model; + + // slot ids on model viewer + const characterItems = model.items + ? model.items.filter((e) => !NOT_DISPLAYED_SLOTS.includes(e[0])) + : []; + const options = getCharacterOptions(model, fullOptions); + let charCustomization = { + options: options, + }; + const ret = { + items: characterItems, + models: { + id: race * 2 - 1 + gender, + type: modelingType.CHARACTER, + }, + }; + if (!model.noCharCustomization) { + ret.charCustomization = charCustomization; + } + return ret; +} + +/** + * + * @param item{number}: Item id + * @param slot{number}: Item slot number + * @param displayId{number}: DisplayId of the item + * @param env {('classic'|'live')}: select game env + * @return {Promise} + */ +async function getDisplaySlot(item, slot, displayId, env = `live`) { + if (typeof item !== `number`) { + throw new Error(`item must be a number`); + } + + if (typeof slot !== `number`) { + throw new Error(`slot must be a number`); + } + + if (typeof displayId !== `number`) { + throw new Error(`displayId must be a number`); + } + + try { + const jsonPath = + env === `classic` && [21, 22].includes(slot) + ? `${window.CONTENT_PATH}meta/item/${displayId}.json` + : `${window.CONTENT_PATH}meta/armor/${slot}/${displayId}.json`; + await fetch(jsonPath).then((response) => response.json()); + + return { + displaySlot: slot, + displayId: displayId, + }; + } catch (e) { + if (!window.WOTLK_TO_RETAIL_DISPLAY_ID_API) { + throw Error( + `Item not found and window.WOTLK_TO_RETAIL_DISPLAY_ID_API not set` + ); + } + const resp = await fetch( + `${window.WOTLK_TO_RETAIL_DISPLAY_ID_API}/${item}/${displayId}` + ).then((response) => response.json()); + const res = resp.data || resp; + if (res.newDisplayId !== displayId) { + return { + displaySlot: slot, + displayId: res.newDisplayId, + }; + } + } + + // old slots to new slots + const retSlot = { + 5: 20, // chest + 16: 21, // main hand + 18: 22, // off hand + }[slot]; + + if (!retSlot) { + console.warn( + `Item: ${item} display: ${displayId} or slot: ${slot} not found for ` + ); + + return { + displaySlot: slot, + displayId: displayId, + }; + } + + return { + displaySlot: retSlot, + displayId: displayId, + }; +} + +/** + * Returns a 2-dimensional list the inner list contains on first position the item slot, the second the item + * display-id ex: [[1,1170],[3,4925]] + * @param {*[{item: {entry: number, displayid: number}, transmog: {entry: number, displayid: number}, slot: number}]} equipments + * @param env {('classic'|'live')}: select game enve + * @returns {Promise} + */ +async function findItemsInEquipments(equipments, env = `live`) { + for (const equipment of equipments) { + if (NOT_DISPLAYED_SLOTS.includes(equipment.slot)) { + continue; + } + + const displayedItem = + Object.keys(equipment.transmog).length !== 0 + ? equipment.transmog + : equipment.item; + const displaySlot = await getDisplaySlot( + displayedItem.entry, + equipment.slot, + displayedItem.displayid, + env + ); + equipment.displaySlot = displaySlot.displaySlot; + equipment.displayId = displaySlot.displayId; + Object.assign(displaySlot, equipment); + } + return equipments + .filter((e) => e.displaySlot) + .map((e) => [e.displaySlot, e.displayId]); +} + +/** + * + * @param {number} race + * @param {number} gender + * @returns {Promise} + */ +async function findRaceGenderOptions(race, gender) { + const raceGender = race * 2 - 1 + gender; + const options = await fetch( + `${window.CONTENT_PATH}meta/charactercustomization/${raceGender}.json` + ).then((response) => response.json()); + if (options.data) { + return options.data; + } + + return options; +} + +export { + characterPart, + findItemsInEquipments, + findRaceGenderOptions, + getCharacterOptions, + getDisplaySlot, + modelingType, + optionsFromModel, +}; diff --git a/src/acore-wp-plugin/web/libraries/wow-model-viewer/index.js b/src/acore-wp-plugin/web/libraries/wow-model-viewer/index.js new file mode 100644 index 00000000..2c266355 --- /dev/null +++ b/src/acore-wp-plugin/web/libraries/wow-model-viewer/index.js @@ -0,0 +1,75 @@ +/* Author: https://github.com/Miorey/wow-model-viewer */ +import { + findItemsInEquipments, + findRaceGenderOptions, + getDisplaySlot, + modelingType, + optionsFromModel, +} from "./character_modeling.js"; +import { WowModelViewer } from "./wow_model_viewer.js"; + +import "./setup.js"; + +/** + * + * @param aspect {number}: Size of the character + * @param containerSelector {string}: jQuery selector on the container + * @param model {{}|{id: number, type: number}}: A json representation of a character + * @param env {('classic'|'live')}: select game enve + * @returns {Promise} + */ +async function generateModels(aspect, containerSelector, model, env = `live`) { + let modelOptions; + let fullOptions; + if (model.id && model.type) { + const { id, type } = model; + modelOptions = { models: { id, type } }; + } else { + const { race, gender } = model; + + // CHARACTER OPTIONS + // This is how we describe a character properties + fullOptions = await findRaceGenderOptions(race, gender); + modelOptions = optionsFromModel(model, fullOptions); + } + if (env === `classic`) { + modelOptions = { + dataEnv: `classic`, + env: `classic`, + gameDataEnv: `classic`, + hd: false, + ...modelOptions, + }; + } else { + modelOptions = { + hd: true, + ...modelOptions, + }; + } + const models = { + type: 2, + contentPath: window.CONTENT_PATH, + // eslint-disable-next-line no-undef + container: jQuery(containerSelector), + aspect: aspect, + ...modelOptions, + }; + // console.log(`Creating viewer with options`, models); + + // eslint-disable-next-line no-undef + const wowModelViewer = await new WowModelViewer(models); + if (fullOptions) { + wowModelViewer.currentCharacterOptions = fullOptions; + wowModelViewer.characterGender = model.gender; + wowModelViewer.characterRace = model.race; + } + return wowModelViewer; +} + +export { + findItemsInEquipments, + findRaceGenderOptions, + generateModels, + getDisplaySlot, + modelingType, +}; diff --git a/src/acore-wp-plugin/web/libraries/wow-model-viewer/setup.js b/src/acore-wp-plugin/web/libraries/wow-model-viewer/setup.js new file mode 100644 index 00000000..0dc414b9 --- /dev/null +++ b/src/acore-wp-plugin/web/libraries/wow-model-viewer/setup.js @@ -0,0 +1,56 @@ +window.CONTENT_PATH = `https://wowgaming.altervista.org/modelviewer/data/get.php?path=`; + +/* Author: https://github.com/Miorey/wow-model-viewer */ + +class WebP { + getImageExtension() { + return `.webp`; + } +} + +if (!window.WH) { + window.WH = {}; + window.WH.debug = function (...args) { + console.log(args); + }; + window.WH.defaultAnimation = `Stand`; + window.WH.WebP = new WebP(); + window.WH.Wow = { + Item: { + INVENTORY_TYPE_HEAD: 1, + INVENTORY_TYPE_NECK: 2, + INVENTORY_TYPE_SHOULDERS: 3, + INVENTORY_TYPE_SHIRT: 4, + INVENTORY_TYPE_CHEST: 5, + INVENTORY_TYPE_WAIST: 6, + INVENTORY_TYPE_LEGS: 7, + INVENTORY_TYPE_FEET: 8, + INVENTORY_TYPE_WRISTS: 9, + INVENTORY_TYPE_HANDS: 10, + INVENTORY_TYPE_FINGER: 11, + INVENTORY_TYPE_TRINKET: 12, + INVENTORY_TYPE_ONE_HAND: 13, + INVENTORY_TYPE_SHIELD: 14, + INVENTORY_TYPE_RANGED: 15, + INVENTORY_TYPE_BACK: 16, + INVENTORY_TYPE_TWO_HAND: 17, + INVENTORY_TYPE_BAG: 18, + INVENTORY_TYPE_TABARD: 19, + INVENTORY_TYPE_ROBE: 20, + INVENTORY_TYPE_MAIN_HAND: 21, + INVENTORY_TYPE_OFF_HAND: 22, + INVENTORY_TYPE_HELD_IN_OFF_HAND: 23, + INVENTORY_TYPE_PROJECTILE: 24, + INVENTORY_TYPE_THROWN: 25, + INVENTORY_TYPE_RANGED_RIGHT: 26, + INVENTORY_TYPE_QUIVER: 27, + INVENTORY_TYPE_RELIC: 28, + INVENTORY_TYPE_PROFESSION_TOOL: 29, + INVENTORY_TYPE_PROFESSION_ACCESSORY: 30, + }, + }; + // eslint-disable-next-line no-undef +} +const WH = window.WH; + +export { WH }; diff --git a/src/acore-wp-plugin/web/libraries/wow-model-viewer/wow_model_viewer.js b/src/acore-wp-plugin/web/libraries/wow-model-viewer/wow_model_viewer.js new file mode 100644 index 00000000..b2acc30c --- /dev/null +++ b/src/acore-wp-plugin/web/libraries/wow-model-viewer/wow_model_viewer.js @@ -0,0 +1,160 @@ +/* Author: https://github.com/Miorey/wow-model-viewer */ +import { getCharacterOptions } from "./character_modeling.js"; + +// eslint-disable-next-line no-undef +class WowModelViewer extends ZamModelViewer { + /** + * Returns the list of animation names + * @returns {Array.} + */ + getListAnimations() { + return [...new Set(this.renderer.actors[0].d.al.q.map((e) => e.e))]; + } + + /** + * Change character distance + * @param {number} val + */ + setDistance(val) { + this.renderer.distance = val; + } + + /** + * Change the animation + * @param {string} val + */ + setAnimation(val) { + this.renderer.actors[0].setAnimation(val); + } + + /** + * Play / Pause the animation + * @param {boolean} val + */ + setAnimPaused(val) { + this.renderer.actors[0].setAnimPaused(val); + } + + /** + * Set azimuth value this value is the angle to the azimuth based on PI + * @param {number} val + */ + setAzimuth(val) { + this.renderer.azimuth = val; + } + + /** + * Set zenith value this value is the angle to the azimuth based on PI + * @param {number} val + */ + setZenith(val) { + this.renderer.zenith = val; + } + + /** + * Returns azimuth value this value is the angle to the azimuth based on PI + * @return {number} + */ + getAzimuth() { + return this.renderer.azimuth; + } + + /** + * Returns zenith value this value is the angle to the azimuth based on PI + * @return {number} + */ + getZenith() { + return this.renderer.zenith; + } + + /** + * This methode is based on `updateViewer` from Paperdoll.js (https://wow.zamimg.com/js/Paperdoll.js?3ee7ec5121) + * + * @param slot {number}: Item slot number + * @param displayId {number}: Item display id + * @param enchant {number}: Enchant (experimental not tested) + */ + updateItemViewer(slot, displayId, enchant) { + const s = window.WH.Wow.Item; + if (slot === s.INVENTORY_TYPE_SHOULDERS) { + // this.method(`setShouldersOverride`, [this.getShouldersOverrideData()]); + } + const a = slot === s.INVENTORY_TYPE_ROBE ? s.INVENTORY_TYPE_CHEST : slot; + + window.WH.debug(`Clearing model viewer slot:`, a.toString()); + this.method(`clearSlots`, slot.toString()); + if (displayId) { + window.WH.debug( + `Attaching to model viewer slot:`, + slot.toString(), + `Display ID:`, + displayId, + `Enchant Visual:`, + enchant + ); + this.method(`setItems`, [ + [ + { + slot: slot, + display: displayId, + visual: enchant || 0, + }, + ], + ]); + } + } + + setNewAppearance(options) { + if (!this.currentCharacterOptions) { + throw Error(`Character options are not set`); + } + const characterOptions = getCharacterOptions( + options, + this.currentCharacterOptions + ); + const race = this.characterRace; + const gender = this.characterGender; + this.method(`setAppearance`, { + race: race, + gender: gender, + options: characterOptions, + }); + } +} + +// Instance variables +WowModelViewer.prototype._currentCharacterOptions = 0; +WowModelViewer.prototype._characterGender = null; +WowModelViewer.prototype._characterRace = null; + +// Getter and Setter for currentCharacterOptions +Object.defineProperty(WowModelViewer.prototype, `currentCharacterOptions`, { + get: function () { + return this._currentCharacterOptions; + }, + set: function (value) { + this._currentCharacterOptions = value; + }, +}); + +// Getter and Setter for characterGender +Object.defineProperty(WowModelViewer.prototype, `characterGender`, { + get: function () { + return this._characterGender; + }, + set: function (value) { + this._characterGender = value; + }, +}); + +// Getter and Setter for characterRace +Object.defineProperty(WowModelViewer.prototype, `characterRace`, { + get: function () { + return this._characterRace; + }, + set: function (value) { + this._characterRace = value; + }, +}); + +export { WowModelViewer };