998 lines
30 KiB
JavaScript
998 lines
30 KiB
JavaScript
/**
|
|
* 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<Object>} 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']
|
|
};
|
|
}
|
|
};
|
|
|
|
})();
|