/** * AvatarSystem - Main API for Mii-style avatar system * Coordinates data, rendering, animations, and accessories * * Depends on: AvatarData, AvatarAnimations, AvatarRenderer, AvatarAccessories, AccessoryRenderer */ (function() { 'use strict'; // ─── Private State ─────────────────────────────────── // In-memory cache of loaded avatars var avatarCache = new Map(); // Animation state per avatar var animationState = new Map(); // ─── Private Helpers ───────────────────────────────── /** * Validate avatar data structure * @param {Object} data - Avatar data to validate * @returns {boolean} True if valid */ function validateAvatar(data) { if (!data || typeof data !== 'object') return false; if (!data.base || typeof data.base !== 'object') return false; // Check required base fields exist var requiredFields = ['faceShape', 'skinTone', 'eyes', 'eyebrows', 'nose', 'mouth', 'ears', 'hair', 'body', 'clothing']; for (var i = 0; i < requiredFields.length; i++) { if (data.base[requiredFields[i]] === undefined) { return false; } } // Check nested objects if (!data.base.eyes || typeof data.base.eyes !== 'object') return false; if (!data.base.eyes.shape || !data.base.eyes.color) return false; if (!data.base.hair || typeof data.base.hair !== 'object') return false; if (!data.base.hair.style || !data.base.hair.color) return false; if (!data.base.body || typeof data.base.body !== 'object') return false; if (!data.base.body.type) return false; if (!data.base.clothing || typeof data.base.clothing !== 'object') return false; if (!data.base.clothing.style || !data.base.clothing.shirtColor || !data.base.clothing.pantsColor) return false; return true; } /** * Create a default avatar with standard settings * @returns {Object} Default avatar object */ function createDefault() { return { userId: null, base: { faceShape: 'oval', skinTone: '#d4a373', eyes: { shape: 'round', color: 'brown' }, eyebrows: 'natural', nose: 'button', mouth: 'smile', ears: 'normal', hair: { style: 'short-messy', color: '#2c1810' }, facialHair: 'none', body: { type: 'average' }, clothing: { style: 'casual', shirtColor: '#3498db', pantsColor: '#2c3e50' } }, earned: { eyewear: [], headwear: [], effects: [] }, equipped: { eyewear: null, headwear: null, effect: null }, level: 1, xp: 0 }; } /** * Deep clone an avatar object * @param {Object} avatar - Avatar to clone * @returns {Object} Cloned avatar */ function cloneAvatar(avatar) { return JSON.parse(JSON.stringify(avatar)); } /** * Merge customization into base avatar * @param {Object} target - Target avatar base * @param {Object} source - Source customization * @returns {Object} Merged base object */ function deepMergeBase(target, source) { var result = Object.assign({}, target); for (var key in source) { if (source.hasOwnProperty(key)) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = Object.assign({}, target[key] || {}, source[key]); } else { result[key] = source[key]; } } } return result; } /** * Get animation frame based on time * @param {Object} anim - Animation definition * @param {number} startTime - Animation start time * @param {boolean} loop - Whether to loop * @returns {Object} Frame info with index and complete flag */ function getAnimationFrame(anim, startTime, loop) { var elapsed = performance.now() - startTime; var totalDuration = anim.frameDuration * anim.frameCount; if (!loop && elapsed >= totalDuration) { return { frame: anim.frameCount - 1, complete: true }; } var frameIndex = Math.floor((elapsed / anim.frameDuration) % anim.frameCount); return { frame: frameIndex, complete: false }; } // ─── Public API ────────────────────────────────────── window.AvatarSystem = { // ─── Creation ────────────────────────────────────── /** * Create a new avatar from customization choices * @param {Object} customization - Full base customization object * @returns {Object} Complete avatar object */ create: function(customization) { var avatar = createDefault(); if (customization) { avatar.base = deepMergeBase(avatar.base, customization); } return avatar; }, /** * Load user's saved avatar (from API or storage) * @param {string} userId - User ID to load avatar for * @returns {Promise} Avatar object */ loadUserAvatar: function(userId) { var self = this; return new Promise(function(resolve) { // Check cache first if (avatarCache.has(userId)) { resolve(cloneAvatar(avatarCache.get(userId))); return; } // In production, this would fetch from API // For now, check localStorage as fallback var storageKey = 'avatar_' + userId; var stored = localStorage.getItem(storageKey); if (stored) { try { var parsed = JSON.parse(stored); if (validateAvatar(parsed)) { avatarCache.set(userId, parsed); resolve(cloneAvatar(parsed)); return; } } catch (e) { console.warn('AvatarSystem: Failed to parse stored avatar', e); } } // Return a guest avatar for unknown users var guest = typeof AvatarData !== 'undefined' && AvatarData.getRandomGuestAvatar ? AvatarData.getRandomGuestAvatar() : { base: createDefault().base }; var avatar = createDefault(); avatar.userId = userId; avatar.base = Object.assign({}, avatar.base, guest.base); resolve(avatar); }); }, /** * Save avatar to cache (and would persist to API) * @param {Object} avatar - Avatar to save * @returns {Object} Saved avatar */ saveAvatar: function(avatar) { if (avatar.userId) { avatarCache.set(avatar.userId, cloneAvatar(avatar)); // Also save to localStorage for persistence var storageKey = 'avatar_' + avatar.userId; try { localStorage.setItem(storageKey, JSON.stringify(avatar)); } catch (e) { console.warn('AvatarSystem: Failed to save avatar to localStorage', e); } } return avatar; }, /** * Clear avatar from cache * @param {string} userId - User ID to clear */ clearCache: function(userId) { if (userId) { avatarCache.delete(userId); } else { avatarCache.clear(); } }, // ─── Rendering ───────────────────────────────────── /** * Render avatar in specified mode * @param {Object} avatar - Avatar object * @param {string} mode - 'HEAD_ONLY', 'HEAD_AND_SHOULDERS', 'UPPER_BODY', 'FULL_BODY' * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} x - Center X position * @param {number} y - Center Y position * @param {Object} options - { scale, animation, frame, time } */ render: function(avatar, mode, ctx, x, y, options) { options = options || {}; var scale = options.scale || 1; var time = options.time !== undefined ? options.time : performance.now() / 1000; // Get animation transforms if animating var transforms = {}; if (options.animation && typeof AvatarAnimations !== 'undefined') { var anim = AvatarAnimations.getAnimation(mode, options.animation); if (anim) { var frame; if (options.frame !== undefined) { frame = options.frame; } else { frame = Math.floor((time * 1000 / anim.frameDuration) % anim.frameCount); } transforms = anim.frames[frame] || {}; } } // Render base avatar if (typeof AvatarRenderer !== 'undefined') { AvatarRenderer.render(ctx, avatar, mode, x, y, { scale: scale, transforms: transforms }); } else { console.warn('AvatarSystem: AvatarRenderer not loaded'); } // Render accessories on top if (typeof AccessoryRenderer !== 'undefined') { AccessoryRenderer.render(ctx, avatar, mode, x, y, scale, time); } }, /** * Render avatar to an offscreen canvas * @param {Object} avatar - Avatar object * @param {string} mode - Rendering mode * @param {Object} options - Render options * @returns {HTMLCanvasElement} Canvas with rendered avatar */ renderToCanvas: function(avatar, mode, options) { options = options || {}; var modeData = this.getModeSize(mode); var scale = options.scale || 1; var canvas = document.createElement('canvas'); canvas.width = modeData.width * scale; canvas.height = modeData.height * scale; var ctx = canvas.getContext('2d'); var centerX = canvas.width / 2; var centerY = canvas.height / 2; this.render(avatar, mode, ctx, centerX, centerY, options); return canvas; }, /** * Render avatar to a data URL * @param {Object} avatar - Avatar object * @param {string} mode - Rendering mode * @param {Object} options - Render options * @returns {string} Data URL of rendered avatar */ renderToDataURL: function(avatar, mode, options) { var canvas = this.renderToCanvas(avatar, mode, options); return canvas.toDataURL('image/png'); }, // ─── Animation ───────────────────────────────────── /** * Get current animation frame transforms * @param {Object} avatar - Avatar object (unused but kept for API consistency) * @param {string} animation - Animation ID * @param {number} frame - Frame number * @returns {Object} Transform data for the frame */ animate: function(avatar, animation, frame) { if (typeof AvatarAnimations === 'undefined') { return {}; } var anim = AvatarAnimations.getAnimation('FULL_BODY', animation); if (!anim) return {}; var f = frame % anim.frameCount; return anim.frames[f] || {}; }, /** * Start an animation for an avatar * @param {string} avatarId - Avatar/user ID * @param {string} animation - Animation ID * @param {Object} options - { loop, onComplete, mode } */ startAnimation: function(avatarId, animation, options) { options = options || {}; animationState.set(avatarId, { animation: animation, startTime: performance.now(), loop: options.loop !== false, mode: options.mode || 'FULL_BODY', onComplete: options.onComplete || null }); }, /** * Stop animation for an avatar * @param {string} avatarId - Avatar/user ID */ stopAnimation: function(avatarId) { var state = animationState.get(avatarId); if (state && state.onComplete) { state.onComplete(); } animationState.delete(avatarId); }, /** * Get current animation state for an avatar * @param {string} avatarId - Avatar/user ID * @returns {Object|null} Current animation state or null */ getAnimationState: function(avatarId) { var state = animationState.get(avatarId); if (!state) return null; if (typeof AvatarAnimations === 'undefined') { return { animation: state.animation, frame: 0, complete: false }; } var anim = AvatarAnimations.getAnimation(state.mode, state.animation); if (!anim) return null; var frameInfo = getAnimationFrame(anim, state.startTime, state.loop); // Handle completion if (frameInfo.complete && !state.loop) { if (state.onComplete) { state.onComplete(); } animationState.delete(avatarId); } return { animation: state.animation, frame: frameInfo.frame, complete: frameInfo.complete, transforms: anim.frames[frameInfo.frame] || {} }; }, /** * Check if avatar is currently animating * @param {string} avatarId - Avatar/user ID * @returns {boolean} True if animating */ isAnimating: function(avatarId) { return animationState.has(avatarId); }, // ─── Accessories ─────────────────────────────────── /** * Equip an accessory * @param {Object} avatar - Avatar object * @param {string} slot - 'eyewear', 'headwear', 'effect' * @param {string} itemId - Accessory ID or null to unequip * @returns {boolean} True if successfully equipped */ equipAccessory: function(avatar, slot, itemId) { if (!avatar.equipped) { avatar.equipped = { eyewear: null, headwear: null, effect: null }; } // Allow unequipping (null/undefined) if (!itemId) { avatar.equipped[slot] = null; return true; } // Verify user has earned this item var earnedKey = slot === 'effect' ? 'effects' : slot; var earned = avatar.earned && avatar.earned[earnedKey]; if (!earned || !earned.includes(itemId)) { console.warn('AvatarSystem: Cannot equip unearned item', itemId); return false; } avatar.equipped[slot] = itemId; return true; }, /** * Unequip an accessory from a slot * @param {Object} avatar - Avatar object * @param {string} slot - 'eyewear', 'headwear', 'effect' * @returns {boolean} True if successfully unequipped */ unequipAccessory: function(avatar, slot) { return this.equipAccessory(avatar, slot, null); }, /** * Unlock/award an accessory to a user * @param {Object} avatar - Avatar object * @param {string} itemId - Accessory ID to unlock * @returns {boolean} True if newly unlocked, false if already owned */ unlockAccessory: function(avatar, itemId) { if (typeof AvatarAccessories === 'undefined') { console.warn('AvatarSystem: AvatarAccessories not loaded'); return false; } var item = AvatarAccessories.getById(itemId); if (!item) { console.warn('AvatarSystem: Unknown accessory', itemId); return false; } var category = item.category === 'effect' ? 'effects' : item.category; // Initialize earned structure if needed if (!avatar.earned) { avatar.earned = { eyewear: [], headwear: [], effects: [] }; } if (!avatar.earned[category]) { avatar.earned[category] = []; } // Check if already owned if (avatar.earned[category].includes(itemId)) { return false; } avatar.earned[category].push(itemId); return true; }, /** * Get all accessories a user has earned * @param {Object} avatar - Avatar object * @returns {Object} Object with eyewear, headwear, effects arrays of accessory objects */ getEarnedAccessories: function(avatar) { var result = { eyewear: [], headwear: [], effects: [] }; if (!avatar.earned) return result; if (typeof AvatarAccessories === 'undefined') { return result; } if (avatar.earned.eyewear) { result.eyewear = avatar.earned.eyewear .map(function(id) { return AvatarAccessories.getById(id); }) .filter(function(item) { return item !== null; }); } if (avatar.earned.headwear) { result.headwear = avatar.earned.headwear .map(function(id) { return AvatarAccessories.getById(id); }) .filter(function(item) { return item !== null; }); } if (avatar.earned.effects) { result.effects = avatar.earned.effects .map(function(id) { return AvatarAccessories.getById(id); }) .filter(function(item) { return item !== null; }); } return result; }, /** * Get currently equipped accessories * @param {Object} avatar - Avatar object * @returns {Object} Object with equipped accessory objects */ getEquippedAccessories: function(avatar) { var result = { eyewear: null, headwear: null, effect: null }; if (!avatar.equipped) return result; if (typeof AvatarAccessories === 'undefined') { return result; } if (avatar.equipped.eyewear) { result.eyewear = AvatarAccessories.getById(avatar.equipped.eyewear); } if (avatar.equipped.headwear) { result.headwear = AvatarAccessories.getById(avatar.equipped.headwear); } if (avatar.equipped.effect) { result.effect = AvatarAccessories.getById(avatar.equipped.effect); } return result; }, /** * Check what accessories should unlock at a level * @param {Object} avatar - Avatar object * @param {number} newLevel - New level reached * @returns {Array} Array of newly unlocked accessory objects */ checkLevelUnlocks: function(avatar, newLevel) { if (typeof AvatarAccessories === 'undefined') { return []; } var newItems = AvatarAccessories.getNewlyUnlockedAtLevel(newLevel); var unlocked = []; var self = this; newItems.forEach(function(item) { if (self.unlockAccessory(avatar, item.id)) { unlocked.push(item); } }); return unlocked; }, /** * Get the full accessory catalog * @returns {Object} Full accessory catalog */ getAccessoryCatalog: function() { if (typeof AvatarAccessories === 'undefined') { return { eyewear: [], headwear: [], effects: [] }; } return AvatarAccessories.getCatalog(); }, /** * Check if avatar has a specific accessory * @param {Object} avatar - Avatar object * @param {string} itemId - Accessory ID * @returns {boolean} True if owned */ hasAccessory: function(avatar, itemId) { if (!avatar.earned) return false; for (var category in avatar.earned) { if (avatar.earned[category] && avatar.earned[category].includes(itemId)) { return true; } } return false; }, // ─── Export for Games ────────────────────────────── /** * Prepare avatar for use in a game * Returns a simplified object optimized for game rendering * @param {Object} avatar - Avatar object * @param {string} mode - Rendering mode * @returns {Object} Game-ready avatar object */ exportForGame: function(avatar, mode) { var self = this; var modeData = this.getModeSize(mode); var cloned = cloneAvatar(avatar); var animations = {}; if (typeof AvatarAnimations !== 'undefined') { animations = AvatarAnimations.getAnimationsForMode(mode); } return { mode: mode, width: modeData.width, height: modeData.height, avatar: cloned, animations: animations, /** * Render the avatar * @param {CanvasRenderingContext2D} ctx * @param {number} x - X position * @param {number} y - Y position * @param {Object} options - Render options */ render: function(ctx, x, y, options) { self.render(cloned, mode, ctx, x, y, options); }, /** * Get animation transforms * @param {string} animation - Animation ID * @param {number} frame - Frame number * @returns {Object} Transform data */ animate: function(animation, frame) { return self.animate(cloned, animation, frame); }, /** * Get animation info * @param {string} animationId - Animation ID * @returns {Object|null} Animation info */ getAnimationInfo: function(animationId) { if (typeof AvatarAnimations === 'undefined') return null; return AvatarAnimations.getAnimation(mode, animationId); } }; }, /** * Export avatar as minimal data for network transmission * @param {Object} avatar - Avatar object * @returns {Object} Minimal avatar data */ exportMinimal: function(avatar) { return { base: avatar.base, equipped: avatar.equipped, level: avatar.level }; }, /** * Import avatar from minimal data * @param {Object} minimalData - Minimal avatar data * @param {string} userId - User ID * @returns {Object} Full avatar object */ importMinimal: function(minimalData, userId) { var avatar = createDefault(); avatar.userId = userId; if (minimalData.base) { avatar.base = deepMergeBase(avatar.base, minimalData.base); } if (minimalData.equipped) { avatar.equipped = Object.assign({}, avatar.equipped, minimalData.equipped); } if (minimalData.level !== undefined) { avatar.level = minimalData.level; } return avatar; }, // ─── Guest Avatars ───────────────────────────────── /** * Get a random guest avatar (for non-logged-in users) * @returns {Object} Guest avatar object */ getGuestAvatar: function() { var guest; if (typeof AvatarData !== 'undefined' && AvatarData.getRandomGuestAvatar) { guest = AvatarData.getRandomGuestAvatar(); } else { guest = { base: createDefault().base }; } var avatar = createDefault(); avatar.userId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); avatar.base = Object.assign({}, avatar.base, guest.base); avatar.isGuest = true; return avatar; }, /** * Check if avatar is a guest * @param {Object} avatar - Avatar object * @returns {boolean} True if guest avatar */ isGuest: function(avatar) { return avatar.isGuest === true || (avatar.userId && avatar.userId.startsWith('guest_')); }, /** * Convert guest avatar to registered user avatar * @param {Object} guestAvatar - Guest avatar * @param {string} userId - New user ID * @returns {Object} Converted avatar */ convertGuestToUser: function(guestAvatar, userId) { var avatar = cloneAvatar(guestAvatar); avatar.userId = userId; delete avatar.isGuest; return avatar; }, // ─── Level & XP ──────────────────────────────────── /** * Add XP to avatar and check for level ups * @param {Object} avatar - Avatar object * @param {number} xpAmount - XP to add * @returns {Object} Result with newLevel, leveledUp, unlockedItems */ addXP: function(avatar, xpAmount) { var oldLevel = avatar.level || 1; avatar.xp = (avatar.xp || 0) + xpAmount; // Calculate new level (100 XP per level, increasing by 50 each level) var xpNeeded = 0; var level = 0; var remainingXP = avatar.xp; while (remainingXP >= 0) { level++; var xpForLevel = 100 + (level - 1) * 50; if (remainingXP < xpForLevel) break; remainingXP -= xpForLevel; } avatar.level = level; var result = { newLevel: level, leveledUp: level > oldLevel, unlockedItems: [] }; // Check for unlocks at each new level if (result.leveledUp) { for (var l = oldLevel + 1; l <= level; l++) { var unlocked = this.checkLevelUnlocks(avatar, l); result.unlockedItems = result.unlockedItems.concat(unlocked); } } return result; }, /** * Get XP needed for next level * @param {Object} avatar - Avatar object * @returns {Object} XP info */ getXPInfo: function(avatar) { var level = avatar.level || 1; var totalXP = avatar.xp || 0; // Calculate XP used for previous levels var xpUsed = 0; for (var l = 1; l < level; l++) { xpUsed += 100 + (l - 1) * 50; } var currentLevelXP = totalXP - xpUsed; var xpForCurrentLevel = 100 + (level - 1) * 50; return { level: level, totalXP: totalXP, currentLevelXP: currentLevelXP, xpNeededForLevel: xpForCurrentLevel, progress: currentLevelXP / xpForCurrentLevel }; }, // ─── Utilities ───────────────────────────────────── /** * Create a sprite sheet for an avatar * Useful for game performance * @param {Object} avatar - Avatar object * @param {string} mode - Rendering mode * @param {Array} animations - Animation IDs (optional, defaults to all) * @returns {Object} Sprite sheet data */ createSpriteSheet: function(avatar, mode, animations) { var modeData = this.getModeSize(mode); var self = this; // Get animation list if (!animations && typeof AvatarAnimations !== 'undefined') { var allAnims = AvatarAnimations.getAnimationsForMode(mode); animations = Object.keys(allAnims); } animations = animations || []; // Calculate sprite sheet dimensions var maxFrames = 0; var animData = []; animations.forEach(function(animId) { if (typeof AvatarAnimations !== 'undefined') { var anim = AvatarAnimations.getAnimation(mode, animId); if (anim) { maxFrames = Math.max(maxFrames, anim.frameCount); animData.push({ id: animId, frames: anim.frameCount, duration: anim.frameDuration }); } } }); // Handle case with no animations if (animData.length === 0) { animData.push({ id: 'idle', frames: 1, duration: 100 }); maxFrames = 1; } // Create canvas var canvas = document.createElement('canvas'); canvas.width = modeData.width * maxFrames; canvas.height = modeData.height * animData.length; var ctx = canvas.getContext('2d'); // Render each animation frame animData.forEach(function(anim, row) { for (var f = 0; f < anim.frames; f++) { var x = modeData.width * f + modeData.width / 2; var y = modeData.height * row + modeData.height / 2; self.render(avatar, mode, ctx, x, y, { animation: anim.id, frame: f }); } }); return { canvas: canvas, image: canvas, animations: animData, frameWidth: modeData.width, frameHeight: modeData.height, /** * Get frame coordinates for an animation frame * @param {string} animId - Animation ID * @param {number} frame - Frame number * @returns {Object} Source rectangle {x, y, width, height} */ getFrameRect: function(animId, frame) { var row = 0; for (var i = 0; i < animData.length; i++) { if (animData[i].id === animId) { row = i; break; } } var f = frame % (animData[row] ? animData[row].frames : 1); return { x: f * modeData.width, y: row * modeData.height, width: modeData.width, height: modeData.height }; } }; }, /** * Get rendering mode dimensions * @param {string} mode - Rendering mode * @returns {Object} Mode dimensions {width, height} */ getModeSize: function(mode) { if (typeof AvatarAnimations !== 'undefined' && AvatarAnimations.getMode) { return AvatarAnimations.getMode(mode); } // Fallback defaults var defaults = { 'HEAD_ONLY': { width: 64, height: 64 }, 'HEAD_AND_SHOULDERS': { width: 96, height: 96 }, 'UPPER_BODY': { width: 128, height: 128 }, 'FULL_BODY': { width: 128, height: 192 } }; return defaults[mode] || defaults['HEAD_ONLY']; }, /** * Validate avatar data structure * @param {Object} avatar - Avatar to validate * @returns {boolean} True if valid */ validate: validateAvatar, /** * Create default avatar * @returns {Object} Default avatar object */ createDefault: createDefault, /** * Clone an avatar object * @param {Object} avatar - Avatar to clone * @returns {Object} Cloned avatar */ clone: cloneAvatar, /** * Update avatar base properties * @param {Object} avatar - Avatar to update * @param {Object} updates - Properties to update * @returns {Object} Updated avatar */ updateBase: function(avatar, updates) { avatar.base = deepMergeBase(avatar.base, updates); return avatar; }, /** * Get available customization options * @returns {Object} Available options from AvatarData */ getCustomizationOptions: function() { if (typeof AvatarData === 'undefined') { return {}; } return { faceShapes: AvatarData.FACE_SHAPES || [], skinTones: AvatarData.SKIN_TONES || [], eyeShapes: AvatarData.EYE_SHAPES || [], eyeColors: AvatarData.EYE_COLORS || [], eyebrowShapes: AvatarData.EYEBROW_SHAPES || [], noseShapes: AvatarData.NOSE_SHAPES || [], mouthShapes: AvatarData.MOUTH_SHAPES || [], earShapes: AvatarData.EAR_SHAPES || [], hairStyles: AvatarData.HAIR_STYLES || [], hairColors: AvatarData.HAIR_COLORS || [], facialHair: AvatarData.FACIAL_HAIR || [], bodyTypes: AvatarData.BODY_TYPES || [], clothingStyles: AvatarData.CLOTHING_STYLES || [] }; }, /** * Compare two avatars for equality * @param {Object} a - First avatar * @param {Object} b - Second avatar * @returns {boolean} True if equivalent */ equals: function(a, b) { return JSON.stringify(a) === JSON.stringify(b); }, /** * Get version info * @returns {Object} Version information */ getVersion: function() { return { version: '1.0.0', dependencies: ['AvatarData', 'AvatarAnimations', 'AvatarRenderer', 'AvatarAccessories', 'AccessoryRenderer'] }; } }; })();