Initial commit
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* Avatar Accessories System
|
||||
* Defines all unlockable accessories for the avatar customization system
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// =============================================================================
|
||||
// RARITY COLORS
|
||||
// =============================================================================
|
||||
var RARITY_COLORS = {
|
||||
common: '#9d9d9d',
|
||||
uncommon: '#1eff00',
|
||||
rare: '#0070dd',
|
||||
epic: '#a335ee',
|
||||
legendary: '#ff8000',
|
||||
mythic: '#e6cc80'
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// EYEWEAR (15 items)
|
||||
// =============================================================================
|
||||
var EYEWEAR = [
|
||||
{
|
||||
id: 'sunglasses-classic',
|
||||
name: 'Classic Sunglasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 5,
|
||||
rarity: 'common',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Timeless shades for any occasion'
|
||||
},
|
||||
{
|
||||
id: 'aviator',
|
||||
name: 'Aviator Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 10,
|
||||
rarity: 'common',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -1 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Top Gun approved eyewear'
|
||||
},
|
||||
{
|
||||
id: 'nerd-glasses',
|
||||
name: 'Nerd Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 15,
|
||||
rarity: 'common',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Intelligence +100 (cosmetic only)'
|
||||
},
|
||||
{
|
||||
id: '3d-glasses',
|
||||
name: '3D Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 20,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -1 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'See the world in a whole new dimension'
|
||||
},
|
||||
{
|
||||
id: 'goggles',
|
||||
name: 'Goggles',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 25,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -3 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Ready for adventure or science experiments'
|
||||
},
|
||||
{
|
||||
id: 'monocle',
|
||||
name: 'Monocle',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 30,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 8, y: 0 },
|
||||
scale: 0.8,
|
||||
hasEffect: false,
|
||||
description: 'Distinguished and sophisticated'
|
||||
},
|
||||
{
|
||||
id: 'star-glasses',
|
||||
name: 'Star Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 35,
|
||||
rarity: 'rare',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'You are a star!'
|
||||
},
|
||||
{
|
||||
id: 'heart-glasses',
|
||||
name: 'Heart Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 40,
|
||||
rarity: 'rare',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Spread the love wherever you go'
|
||||
},
|
||||
{
|
||||
id: 'sport-visor',
|
||||
name: 'Sport Visor',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 45,
|
||||
rarity: 'rare',
|
||||
zIndex: 10,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -5 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Professional gamer gear'
|
||||
},
|
||||
{
|
||||
id: 'vr-headset',
|
||||
name: 'VR Headset',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 50,
|
||||
rarity: 'epic',
|
||||
zIndex: 11,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -4 },
|
||||
scale: 1.3,
|
||||
hasEffect: false,
|
||||
description: 'Immerse yourself in virtual worlds'
|
||||
},
|
||||
{
|
||||
id: 'cyber-visor',
|
||||
name: 'Cyber Visor',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 55,
|
||||
rarity: 'epic',
|
||||
zIndex: 11,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -3 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Sleek futuristic eye protection'
|
||||
},
|
||||
{
|
||||
id: 'x-ray-specs',
|
||||
name: 'X-Ray Specs',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 60,
|
||||
rarity: 'epic',
|
||||
zIndex: 11,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: true,
|
||||
description: 'See through walls... probably'
|
||||
},
|
||||
{
|
||||
id: 'laser-eyes',
|
||||
name: 'Laser Eyes',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 70,
|
||||
rarity: 'legendary',
|
||||
zIndex: 12,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.0,
|
||||
hasEffect: true,
|
||||
description: 'Pew pew! Lasers shoot from your eyes'
|
||||
},
|
||||
{
|
||||
id: 'diamond-glasses',
|
||||
name: 'Diamond Glasses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 80,
|
||||
rarity: 'legendary',
|
||||
zIndex: 12,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Pure crystallized luxury'
|
||||
},
|
||||
{
|
||||
id: 'cosmic-lenses',
|
||||
name: 'Cosmic Lenses',
|
||||
category: 'eyewear',
|
||||
unlockLevel: 90,
|
||||
rarity: 'mythic',
|
||||
zIndex: 13,
|
||||
anchor: 'eyes',
|
||||
offset: { x: 0, y: -2 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
description: 'Gaze upon the infinite universe'
|
||||
}
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// HEADWEAR (20 items)
|
||||
// =============================================================================
|
||||
var HEADWEAR = [
|
||||
{
|
||||
id: 'baseball-cap',
|
||||
name: 'Baseball Cap',
|
||||
category: 'headwear',
|
||||
unlockLevel: 3,
|
||||
rarity: 'common',
|
||||
zIndex: 8,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -15 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A classic casual cap'
|
||||
},
|
||||
{
|
||||
id: 'backwards-cap',
|
||||
name: 'Backwards Cap',
|
||||
category: 'headwear',
|
||||
unlockLevel: 8,
|
||||
rarity: 'common',
|
||||
zIndex: 8,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -15 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Cool kids wear it backwards'
|
||||
},
|
||||
{
|
||||
id: 'beanie',
|
||||
name: 'Beanie',
|
||||
category: 'headwear',
|
||||
unlockLevel: 12,
|
||||
rarity: 'common',
|
||||
zIndex: 8,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -12 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Cozy knitted headwear'
|
||||
},
|
||||
{
|
||||
id: 'party-hat',
|
||||
name: 'Party Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 18,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -20 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Every day is a celebration!'
|
||||
},
|
||||
{
|
||||
id: 'chef-hat',
|
||||
name: 'Chef Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 22,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -25 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Master of the kitchen'
|
||||
},
|
||||
{
|
||||
id: 'crown-bronze',
|
||||
name: 'Bronze Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 25,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A humble crown for rising royalty'
|
||||
},
|
||||
{
|
||||
id: 'hard-hat',
|
||||
name: 'Hard Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 26,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -14 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Safety first on the job site'
|
||||
},
|
||||
{
|
||||
id: 'wizard-hat',
|
||||
name: 'Wizard Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 30,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -30 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Channel your inner sorcerer'
|
||||
},
|
||||
{
|
||||
id: 'pirate-hat',
|
||||
name: 'Pirate Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 35,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Arrr! Set sail for adventure'
|
||||
},
|
||||
{
|
||||
id: 'santa-hat',
|
||||
name: 'Santa Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 38,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 3, y: -16 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Ho ho ho! Spread holiday cheer'
|
||||
},
|
||||
{
|
||||
id: 'crown-silver',
|
||||
name: 'Silver Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 40,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A noble crown for dedicated players'
|
||||
},
|
||||
{
|
||||
id: 'graduation-cap',
|
||||
name: 'Graduation Cap',
|
||||
category: 'headwear',
|
||||
unlockLevel: 42,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -16 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'Scholar of the gaming arts'
|
||||
},
|
||||
{
|
||||
id: 'bunny-ears',
|
||||
name: 'Bunny Ears',
|
||||
category: 'headwear',
|
||||
unlockLevel: 45,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -22 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Hop into the fun!'
|
||||
},
|
||||
{
|
||||
id: 'devil-horns',
|
||||
name: 'Devil Horns',
|
||||
category: 'headwear',
|
||||
unlockLevel: 48,
|
||||
rarity: 'rare',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -14 },
|
||||
scale: 1.0,
|
||||
hasEffect: false,
|
||||
description: 'A little mischief never hurt anyone'
|
||||
},
|
||||
{
|
||||
id: 'halo',
|
||||
name: 'Halo',
|
||||
category: 'headwear',
|
||||
unlockLevel: 52,
|
||||
rarity: 'epic',
|
||||
zIndex: 7,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -25 },
|
||||
scale: 1.0,
|
||||
hasEffect: true,
|
||||
description: 'An angelic golden ring of light'
|
||||
},
|
||||
{
|
||||
id: 'top-hat',
|
||||
name: 'Top Hat',
|
||||
category: 'headwear',
|
||||
unlockLevel: 56,
|
||||
rarity: 'epic',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -28 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'Dapper and distinguished'
|
||||
},
|
||||
{
|
||||
id: 'crown-gold',
|
||||
name: 'Gold Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 60,
|
||||
rarity: 'epic',
|
||||
zIndex: 10,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -18 },
|
||||
scale: 1.1,
|
||||
hasEffect: false,
|
||||
description: 'True royalty in golden splendor'
|
||||
},
|
||||
{
|
||||
id: 'viking-helmet',
|
||||
name: 'Viking Helmet',
|
||||
category: 'headwear',
|
||||
unlockLevel: 62,
|
||||
rarity: 'epic',
|
||||
zIndex: 9,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -16 },
|
||||
scale: 1.2,
|
||||
hasEffect: false,
|
||||
description: 'Conquer games like a Norse warrior'
|
||||
},
|
||||
{
|
||||
id: 'crown-diamond',
|
||||
name: 'Diamond Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 80,
|
||||
rarity: 'legendary',
|
||||
zIndex: 10,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -20 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
description: 'A magnificent crown of pure diamonds'
|
||||
},
|
||||
{
|
||||
id: 'crown-cosmic',
|
||||
name: 'Cosmic Crown',
|
||||
category: 'headwear',
|
||||
unlockLevel: 100,
|
||||
rarity: 'mythic',
|
||||
zIndex: 11,
|
||||
anchor: 'top',
|
||||
offset: { x: 0, y: -22 },
|
||||
scale: 1.3,
|
||||
hasEffect: true,
|
||||
description: 'The ultimate crown, forged from stardust'
|
||||
}
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// EFFECTS/AURAS (10 items)
|
||||
// =============================================================================
|
||||
var EFFECTS = [
|
||||
{
|
||||
id: 'sparkle-trail',
|
||||
name: 'Sparkle Trail',
|
||||
category: 'effect',
|
||||
unlockLevel: 15,
|
||||
rarity: 'uncommon',
|
||||
zIndex: 1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.0,
|
||||
hasEffect: true,
|
||||
effectType: 'particles',
|
||||
color: '#FFD700',
|
||||
description: 'Leave a trail of golden sparkles'
|
||||
},
|
||||
{
|
||||
id: 'fire-aura',
|
||||
name: 'Fire Aura',
|
||||
category: 'effect',
|
||||
unlockLevel: 25,
|
||||
rarity: 'rare',
|
||||
zIndex: 0,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
effectType: 'glow',
|
||||
color: '#FF4500',
|
||||
description: 'Surrounded by fierce flames'
|
||||
},
|
||||
{
|
||||
id: 'lightning-crackle',
|
||||
name: 'Lightning Crackle',
|
||||
category: 'effect',
|
||||
unlockLevel: 35,
|
||||
rarity: 'rare',
|
||||
zIndex: 2,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.1,
|
||||
hasEffect: true,
|
||||
effectType: 'electric',
|
||||
color: '#00BFFF',
|
||||
description: 'Electricity crackles around you'
|
||||
},
|
||||
{
|
||||
id: 'rainbow-glow',
|
||||
name: 'Rainbow Glow',
|
||||
category: 'effect',
|
||||
unlockLevel: 45,
|
||||
rarity: 'epic',
|
||||
zIndex: 0,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.3,
|
||||
hasEffect: true,
|
||||
effectType: 'glow',
|
||||
color: 'rainbow',
|
||||
description: 'Radiate all colors of the spectrum'
|
||||
},
|
||||
{
|
||||
id: 'star-particles',
|
||||
name: 'Star Particles',
|
||||
category: 'effect',
|
||||
unlockLevel: 55,
|
||||
rarity: 'epic',
|
||||
zIndex: 2,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
effectType: 'particles',
|
||||
color: '#FFFF00',
|
||||
description: 'Tiny stars orbit around you'
|
||||
},
|
||||
{
|
||||
id: 'ice-crystals',
|
||||
name: 'Ice Crystals',
|
||||
category: 'effect',
|
||||
unlockLevel: 65,
|
||||
rarity: 'legendary',
|
||||
zIndex: 2,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.2,
|
||||
hasEffect: true,
|
||||
effectType: 'particles',
|
||||
color: '#87CEEB',
|
||||
description: 'Frozen crystals shimmer around you'
|
||||
},
|
||||
{
|
||||
id: 'shadow-effect',
|
||||
name: 'Shadow Effect',
|
||||
category: 'effect',
|
||||
unlockLevel: 70,
|
||||
rarity: 'legendary',
|
||||
zIndex: -1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.4,
|
||||
hasEffect: true,
|
||||
effectType: 'shadow',
|
||||
color: '#2F2F4F',
|
||||
description: 'Darkness follows your every move'
|
||||
},
|
||||
{
|
||||
id: 'golden-glow',
|
||||
name: 'Golden Glow',
|
||||
category: 'effect',
|
||||
unlockLevel: 75,
|
||||
rarity: 'legendary',
|
||||
zIndex: 0,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.3,
|
||||
hasEffect: true,
|
||||
effectType: 'glow',
|
||||
color: '#FFD700',
|
||||
description: 'Bathe in pure golden light'
|
||||
},
|
||||
{
|
||||
id: 'void-aura',
|
||||
name: 'Void Aura',
|
||||
category: 'effect',
|
||||
unlockLevel: 85,
|
||||
rarity: 'mythic',
|
||||
zIndex: -1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.5,
|
||||
hasEffect: true,
|
||||
effectType: 'void',
|
||||
color: '#4B0082',
|
||||
description: 'The void itself surrounds you'
|
||||
},
|
||||
{
|
||||
id: 'cosmic-trail',
|
||||
name: 'Cosmic Trail',
|
||||
category: 'effect',
|
||||
unlockLevel: 95,
|
||||
rarity: 'mythic',
|
||||
zIndex: 1,
|
||||
anchor: 'head',
|
||||
offset: { x: 0, y: 0 },
|
||||
scale: 1.4,
|
||||
hasEffect: true,
|
||||
effectType: 'cosmic',
|
||||
color: 'cosmic',
|
||||
description: 'Trail galaxies and nebulae in your wake'
|
||||
}
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get all accessories combined into a single array
|
||||
*/
|
||||
function getAll() {
|
||||
return [].concat(EYEWEAR, HEADWEAR, EFFECTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessories filtered by category
|
||||
* @param {string} category - 'eyewear', 'headwear', or 'effect'
|
||||
*/
|
||||
function getByCategory(category) {
|
||||
switch (category) {
|
||||
case 'eyewear':
|
||||
return EYEWEAR.slice();
|
||||
case 'headwear':
|
||||
return HEADWEAR.slice();
|
||||
case 'effect':
|
||||
return EFFECTS.slice();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an accessory by its unique ID
|
||||
* @param {string} id - The accessory ID
|
||||
*/
|
||||
function getById(id) {
|
||||
var all = getAll();
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].id === id) {
|
||||
return all[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accessories of a specific rarity
|
||||
* @param {string} rarity - 'common', 'uncommon', 'rare', 'epic', 'legendary', or 'mythic'
|
||||
*/
|
||||
function getByRarity(rarity) {
|
||||
return getAll().filter(function(item) {
|
||||
return item.rarity === rarity;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accessories unlockable at or before the given level
|
||||
* @param {number} level - The player's current level
|
||||
*/
|
||||
function getUnlockedAtLevel(level) {
|
||||
return getAll().filter(function(item) {
|
||||
return item.unlockLevel <= level;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accessories that unlock exactly at the specified level
|
||||
* @param {number} level - The level to check
|
||||
*/
|
||||
function getNewlyUnlockedAtLevel(level) {
|
||||
return getAll().filter(function(item) {
|
||||
return item.unlockLevel === level;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color associated with a rarity
|
||||
* @param {string} rarity - The rarity level
|
||||
*/
|
||||
function getRarityColor(rarity) {
|
||||
return RARITY_COLORS[rarity] || RARITY_COLORS.common;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an organized catalog for UI display
|
||||
* Returns accessories grouped by category and sorted by unlock level
|
||||
*/
|
||||
function getCatalog() {
|
||||
var sortByLevel = function(a, b) {
|
||||
return a.unlockLevel - b.unlockLevel;
|
||||
};
|
||||
|
||||
return {
|
||||
eyewear: EYEWEAR.slice().sort(sortByLevel),
|
||||
headwear: HEADWEAR.slice().sort(sortByLevel),
|
||||
effects: EFFECTS.slice().sort(sortByLevel),
|
||||
totalCount: EYEWEAR.length + HEADWEAR.length + EFFECTS.length,
|
||||
rarityColors: Object.assign({}, RARITY_COLORS)
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PUBLIC API
|
||||
// =============================================================================
|
||||
window.AvatarAccessories = {
|
||||
// Data arrays
|
||||
EYEWEAR: EYEWEAR,
|
||||
HEADWEAR: HEADWEAR,
|
||||
EFFECTS: EFFECTS,
|
||||
RARITY_COLORS: RARITY_COLORS,
|
||||
|
||||
// Methods
|
||||
getAll: getAll,
|
||||
getByCategory: getByCategory,
|
||||
getById: getById,
|
||||
getByRarity: getByRarity,
|
||||
getUnlockedAtLevel: getUnlockedAtLevel,
|
||||
getNewlyUnlockedAtLevel: getNewlyUnlockedAtLevel,
|
||||
getRarityColor: getRarityColor,
|
||||
getCatalog: getCatalog
|
||||
};
|
||||
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Face shapes (8 options)
|
||||
var FACE_SHAPES = [
|
||||
{ id: 'round', name: 'Round', widthRatio: 1.0, heightRatio: 1.0, jawCurve: 0.9 },
|
||||
{ id: 'oval', name: 'Oval', widthRatio: 0.85, heightRatio: 1.1, jawCurve: 0.7 },
|
||||
{ id: 'square', name: 'Square', widthRatio: 1.0, heightRatio: 0.95, jawCurve: 0.3 },
|
||||
{ id: 'heart', name: 'Heart', widthRatio: 0.9, heightRatio: 1.05, jawCurve: 0.8, chinPoint: 0.6 },
|
||||
{ id: 'long', name: 'Long', widthRatio: 0.8, heightRatio: 1.2, jawCurve: 0.5 },
|
||||
{ id: 'wide', name: 'Wide', widthRatio: 1.15, heightRatio: 0.9, jawCurve: 0.6 },
|
||||
{ id: 'angular', name: 'Angular', widthRatio: 0.95, heightRatio: 1.0, jawCurve: 0.2, cheekbones: 0.7 },
|
||||
{ id: 'soft', name: 'Soft', widthRatio: 1.05, heightRatio: 1.0, jawCurve: 0.95 }
|
||||
];
|
||||
|
||||
// Skin tones (20 options - full diversity)
|
||||
var SKIN_TONES = [
|
||||
{ id: 'fair1', hex: '#FFECD1', name: 'Porcelain' },
|
||||
{ id: 'fair2', hex: '#FFE4C4', name: 'Ivory' },
|
||||
{ id: 'fair3', hex: '#FFDAB9', name: 'Peach' },
|
||||
{ id: 'fair4', hex: '#FFD5B5', name: 'Cream' },
|
||||
{ id: 'light1', hex: '#F5D0A9', name: 'Light Beige' },
|
||||
{ id: 'light2', hex: '#E8C49A', name: 'Warm Beige' },
|
||||
{ id: 'light3', hex: '#DEB887', name: 'Sand' },
|
||||
{ id: 'medium1', hex: '#D4A373', name: 'Golden' },
|
||||
{ id: 'medium2', hex: '#C9A06A', name: 'Honey' },
|
||||
{ id: 'medium3', hex: '#BC8F5F', name: 'Caramel' },
|
||||
{ id: 'medium4', hex: '#B07D4B', name: 'Toffee' },
|
||||
{ id: 'tan1', hex: '#A67B5B', name: 'Tan' },
|
||||
{ id: 'tan2', hex: '#996B4D', name: 'Bronze' },
|
||||
{ id: 'tan3', hex: '#8B6D4C', name: 'Cinnamon' },
|
||||
{ id: 'brown1', hex: '#7D5A3C', name: 'Chestnut' },
|
||||
{ id: 'brown2', hex: '#6F4E37', name: 'Cocoa' },
|
||||
{ id: 'brown3', hex: '#5D4532', name: 'Espresso' },
|
||||
{ id: 'dark1', hex: '#4E3B2D', name: 'Mocha' },
|
||||
{ id: 'dark2', hex: '#3D2B1F', name: 'Ebony' },
|
||||
{ id: 'dark3', hex: '#2C1F15', name: 'Onyx' }
|
||||
];
|
||||
|
||||
// Eye shapes (12 options)
|
||||
var EYE_SHAPES = [
|
||||
{ id: 'round', name: 'Round', width: 0.15, height: 0.12, curve: 1.0 },
|
||||
{ id: 'almond', name: 'Almond', width: 0.18, height: 0.08, curve: 0.6 },
|
||||
{ id: 'oval', name: 'Oval', width: 0.16, height: 0.10, curve: 0.8 },
|
||||
{ id: 'narrow', name: 'Narrow', width: 0.20, height: 0.06, curve: 0.4 },
|
||||
{ id: 'wide', name: 'Wide', width: 0.19, height: 0.11, curve: 0.7 },
|
||||
{ id: 'droopy', name: 'Droopy', width: 0.17, height: 0.09, curve: 0.5, droop: 0.3 },
|
||||
{ id: 'upturned', name: 'Upturned', width: 0.17, height: 0.09, curve: 0.6, upturn: 0.3 },
|
||||
{ id: 'cat', name: 'Cat', width: 0.18, height: 0.07, curve: 0.4, upturn: 0.5 },
|
||||
{ id: 'sleepy', name: 'Sleepy', width: 0.16, height: 0.07, curve: 0.5, lidDroop: 0.4 },
|
||||
{ id: 'surprised', name: 'Surprised', width: 0.16, height: 0.14, curve: 1.0 },
|
||||
{ id: 'angry', name: 'Angry', width: 0.17, height: 0.08, curve: 0.5, innerAngle: -0.2 },
|
||||
{ id: 'happy', name: 'Happy', width: 0.17, height: 0.08, curve: 0.7, squint: 0.3 }
|
||||
];
|
||||
|
||||
// Eye colors (16 options)
|
||||
var EYE_COLORS = [
|
||||
{ id: 'brown', hex: '#634e34', name: 'Brown' },
|
||||
{ id: 'darkBrown', hex: '#3d2314', name: 'Dark Brown' },
|
||||
{ id: 'hazel', hex: '#8b7355', name: 'Hazel' },
|
||||
{ id: 'green', hex: '#3d7a3d', name: 'Green' },
|
||||
{ id: 'blue', hex: '#4682b4', name: 'Blue' },
|
||||
{ id: 'lightBlue', hex: '#87ceeb', name: 'Light Blue' },
|
||||
{ id: 'gray', hex: '#708090', name: 'Gray' },
|
||||
{ id: 'amber', hex: '#b8860b', name: 'Amber' },
|
||||
{ id: 'violet', hex: '#8b008b', name: 'Violet' },
|
||||
{ id: 'emerald', hex: '#50c878', name: 'Emerald' },
|
||||
{ id: 'iceBlue', hex: '#a5d8e6', name: 'Ice Blue' },
|
||||
{ id: 'gold', hex: '#daa520', name: 'Gold' },
|
||||
{ id: 'red', hex: '#8b0000', name: 'Red' },
|
||||
{ id: 'black', hex: '#1a1a1a', name: 'Black' },
|
||||
{ id: 'honey', hex: '#c9a06a', name: 'Honey' },
|
||||
{ id: 'turquoise', hex: '#40e0d0', name: 'Turquoise' }
|
||||
];
|
||||
|
||||
// Eyebrow shapes (10 options)
|
||||
var EYEBROW_SHAPES = [
|
||||
{ id: 'natural', name: 'Natural', thickness: 0.5, arch: 0.3 },
|
||||
{ id: 'arched', name: 'Arched', thickness: 0.5, arch: 0.6 },
|
||||
{ id: 'straight', name: 'Straight', thickness: 0.5, arch: 0.0 },
|
||||
{ id: 'thick', name: 'Thick', thickness: 0.8, arch: 0.3 },
|
||||
{ id: 'thin', name: 'Thin', thickness: 0.25, arch: 0.3 },
|
||||
{ id: 'angry', name: 'Angry', thickness: 0.5, arch: 0.2, innerAngle: -0.4 },
|
||||
{ id: 'worried', name: 'Worried', thickness: 0.5, arch: 0.4, innerAngle: 0.3 },
|
||||
{ id: 'bushy', name: 'Bushy', thickness: 0.9, arch: 0.2, wild: true },
|
||||
{ id: 'sleek', name: 'Sleek', thickness: 0.3, arch: 0.5 },
|
||||
{ id: 'curved', name: 'Curved', thickness: 0.5, arch: 0.7 }
|
||||
];
|
||||
|
||||
// Nose shapes (8 options)
|
||||
var NOSE_SHAPES = [
|
||||
{ id: 'button', name: 'Button', width: 0.12, length: 0.15 },
|
||||
{ id: 'pointed', name: 'Pointed', width: 0.10, length: 0.22 },
|
||||
{ id: 'roman', name: 'Roman', width: 0.14, length: 0.25, bridge: 0.6 },
|
||||
{ id: 'snub', name: 'Snub', width: 0.13, length: 0.12, upturn: 0.4 },
|
||||
{ id: 'wide', name: 'Wide', width: 0.18, length: 0.18 },
|
||||
{ id: 'narrow', name: 'Narrow', width: 0.08, length: 0.20 },
|
||||
{ id: 'aquiline', name: 'Aquiline', width: 0.12, length: 0.24, bridge: 0.8 },
|
||||
{ id: 'flat', name: 'Flat', width: 0.16, length: 0.10 }
|
||||
];
|
||||
|
||||
// Mouth shapes (10 options)
|
||||
var MOUTH_SHAPES = [
|
||||
{ id: 'smile', name: 'Smile', width: 0.25, curve: 0.3 },
|
||||
{ id: 'neutral', name: 'Neutral', width: 0.22, curve: 0 },
|
||||
{ id: 'grin', name: 'Grin', width: 0.30, curve: 0.5, showTeeth: true },
|
||||
{ id: 'smirk', name: 'Smirk', width: 0.22, curve: 0.2, asymmetric: true },
|
||||
{ id: 'pout', name: 'Pout', width: 0.20, curve: -0.2, lipFullness: 0.8 },
|
||||
{ id: 'open', name: 'Open', width: 0.24, curve: 0.1, openness: 0.3 },
|
||||
{ id: 'small', name: 'Small', width: 0.16, curve: 0.1 },
|
||||
{ id: 'wide', name: 'Wide', width: 0.32, curve: 0.1 },
|
||||
{ id: 'thin', name: 'Thin', width: 0.24, curve: 0, lipFullness: 0.3 },
|
||||
{ id: 'full', name: 'Full', width: 0.26, curve: 0.15, lipFullness: 0.9 }
|
||||
];
|
||||
|
||||
// Ear shapes (6 options)
|
||||
var EAR_SHAPES = [
|
||||
{ id: 'normal', name: 'Normal', size: 1.0, angle: 0 },
|
||||
{ id: 'small', name: 'Small', size: 0.7, angle: 0 },
|
||||
{ id: 'large', name: 'Large', size: 1.3, angle: 0 },
|
||||
{ id: 'pointed', name: 'Pointed', size: 1.0, angle: 0, pointed: true },
|
||||
{ id: 'round', name: 'Round', size: 1.0, angle: 0, roundness: 0.9 },
|
||||
{ id: 'flat', name: 'Flat', size: 0.9, angle: 15 }
|
||||
];
|
||||
|
||||
// Hair styles (30 options)
|
||||
var HAIR_STYLES = [
|
||||
{ id: 'short-messy', name: 'Short Messy', length: 'short', coverage: 0.3 },
|
||||
{ id: 'short-neat', name: 'Short Neat', length: 'short', coverage: 0.25 },
|
||||
{ id: 'short-curly', name: 'Short Curly', length: 'short', coverage: 0.35 },
|
||||
{ id: 'medium-straight', name: 'Medium Straight', length: 'medium', coverage: 0.5 },
|
||||
{ id: 'medium-wavy', name: 'Medium Wavy', length: 'medium', coverage: 0.55 },
|
||||
{ id: 'long-straight', name: 'Long Straight', length: 'long', coverage: 0.7 },
|
||||
{ id: 'long-wavy', name: 'Long Wavy', length: 'long', coverage: 0.75 },
|
||||
{ id: 'ponytail', name: 'Ponytail', length: 'medium', coverage: 0.4 },
|
||||
{ id: 'pigtails', name: 'Pigtails', length: 'medium', coverage: 0.45 },
|
||||
{ id: 'bun', name: 'Bun', length: 'long', coverage: 0.35 },
|
||||
{ id: 'mohawk', name: 'Mohawk', length: 'short', coverage: 0.2 },
|
||||
{ id: 'afro', name: 'Afro', length: 'medium', coverage: 0.8 },
|
||||
{ id: 'buzz', name: 'Buzz Cut', length: 'short', coverage: 0.15 },
|
||||
{ id: 'bald', name: 'Bald', length: 'none', coverage: 0 },
|
||||
{ id: 'sidepart', name: 'Side Part', length: 'short', coverage: 0.3 },
|
||||
{ id: 'spiky', name: 'Spiky', length: 'short', coverage: 0.35, spikes: true },
|
||||
{ id: 'slicked', name: 'Slicked Back', length: 'short', coverage: 0.25 },
|
||||
{ id: 'bob', name: 'Bob', length: 'medium', coverage: 0.5 },
|
||||
{ id: 'pixie', name: 'Pixie', length: 'short', coverage: 0.35 },
|
||||
{ id: 'braids', name: 'Braids', length: 'long', coverage: 0.6 },
|
||||
{ id: 'dreads', name: 'Dreads', length: 'long', coverage: 0.7 },
|
||||
{ id: 'undercut', name: 'Undercut', length: 'short', coverage: 0.25, sides: 'shaved' },
|
||||
{ id: 'fauxhawk', name: 'Faux Hawk', length: 'short', coverage: 0.3 },
|
||||
{ id: 'mullet', name: 'Mullet', length: 'medium', coverage: 0.45, backLength: 'long' },
|
||||
{ id: 'bowl', name: 'Bowl Cut', length: 'medium', coverage: 0.55 },
|
||||
{ id: 'combover', name: 'Comb Over', length: 'short', coverage: 0.2 },
|
||||
{ id: 'bangs', name: 'Bangs', length: 'medium', coverage: 0.5, bangs: true },
|
||||
{ id: 'sidesweep', name: 'Side Sweep', length: 'medium', coverage: 0.45 },
|
||||
{ id: 'topknot', name: 'Top Knot', length: 'long', coverage: 0.3 },
|
||||
{ id: 'cornrows', name: 'Cornrows', length: 'medium', coverage: 0.4 }
|
||||
];
|
||||
|
||||
// Hair colors (20 options)
|
||||
var HAIR_COLORS = [
|
||||
{ id: 'black', hex: '#1a1a1a', name: 'Black' },
|
||||
{ id: 'darkBrown', hex: '#2c1810', name: 'Dark Brown' },
|
||||
{ id: 'brown', hex: '#4a3728', name: 'Brown' },
|
||||
{ id: 'lightBrown', hex: '#7a5a3a', name: 'Light Brown' },
|
||||
{ id: 'auburn', hex: '#8b4513', name: 'Auburn' },
|
||||
{ id: 'ginger', hex: '#b5651d', name: 'Ginger' },
|
||||
{ id: 'strawberry', hex: '#c67171', name: 'Strawberry' },
|
||||
{ id: 'blonde', hex: '#d4b896', name: 'Blonde' },
|
||||
{ id: 'platinum', hex: '#e8e4c9', name: 'Platinum' },
|
||||
{ id: 'white', hex: '#f0f0f0', name: 'White' },
|
||||
{ id: 'gray', hex: '#808080', name: 'Gray' },
|
||||
{ id: 'red', hex: '#cc0000', name: 'Red' },
|
||||
{ id: 'blue', hex: '#0066cc', name: 'Blue' },
|
||||
{ id: 'purple', hex: '#7b2d8e', name: 'Purple' },
|
||||
{ id: 'green', hex: '#228b22', name: 'Green' },
|
||||
{ id: 'pink', hex: '#ff69b4', name: 'Pink' },
|
||||
{ id: 'teal', hex: '#008b8b', name: 'Teal' },
|
||||
{ id: 'orange', hex: '#ff6600', name: 'Orange' },
|
||||
{ id: 'silver', hex: '#c0c0c0', name: 'Silver' },
|
||||
{ id: 'rainbow', hex: 'linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #8b00ff)', name: 'Rainbow', isGradient: true }
|
||||
];
|
||||
|
||||
// Facial hair (10 options)
|
||||
var FACIAL_HAIR = [
|
||||
{ id: 'none', name: 'None' },
|
||||
{ id: 'stubble', name: 'Stubble', density: 0.3, length: 0.1 },
|
||||
{ id: 'goatee', name: 'Goatee', coverage: 'chin', length: 0.4 },
|
||||
{ id: 'mustache', name: 'Mustache', coverage: 'upper-lip', length: 0.3, style: 'classic' },
|
||||
{ id: 'fullBeard', name: 'Full Beard', coverage: 'full', length: 0.7 },
|
||||
{ id: 'shortBeard', name: 'Short Beard', coverage: 'full', length: 0.3 },
|
||||
{ id: 'soulPatch', name: 'Soul Patch', coverage: 'chin-center', length: 0.3 },
|
||||
{ id: 'mutton', name: 'Mutton Chops', coverage: 'sides', length: 0.5 },
|
||||
{ id: 'handlebar', name: 'Handlebar', coverage: 'upper-lip', length: 0.4, style: 'handlebar' },
|
||||
{ id: 'vandyke', name: 'Van Dyke', coverage: 'chin-mustache', length: 0.5 }
|
||||
];
|
||||
|
||||
// Body types (6 options)
|
||||
var BODY_TYPES = [
|
||||
{ id: 'slim', name: 'Slim', widthRatio: 0.8, heightRatio: 1.0 },
|
||||
{ id: 'average', name: 'Average', widthRatio: 1.0, heightRatio: 1.0 },
|
||||
{ id: 'athletic', name: 'Athletic', widthRatio: 1.1, heightRatio: 1.0 },
|
||||
{ id: 'stocky', name: 'Stocky', widthRatio: 1.2, heightRatio: 0.95 },
|
||||
{ id: 'tall', name: 'Tall', widthRatio: 0.95, heightRatio: 1.15 },
|
||||
{ id: 'short', name: 'Short', widthRatio: 1.0, heightRatio: 0.85 }
|
||||
];
|
||||
|
||||
// Clothing styles (4 options)
|
||||
var CLOTHING_STYLES = [
|
||||
{ id: 'casual', name: 'Casual', hasCollar: false, sleeveLength: 'short' },
|
||||
{ id: 'sporty', name: 'Sporty', hasCollar: false, sleeveLength: 'short', hasStripes: true },
|
||||
{ id: 'formal', name: 'Formal', hasCollar: true, sleeveLength: 'long' },
|
||||
{ id: 'scifi', name: 'Sci-Fi', hasCollar: true, sleeveLength: 'long', hasGlow: true }
|
||||
];
|
||||
|
||||
// Preset colors for clothing
|
||||
var CLOTHING_COLORS = [
|
||||
'#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c',
|
||||
'#3498db', '#9b59b6', '#34495e', '#95a5a6', '#ecf0f1',
|
||||
'#c0392b', '#d35400', '#f39c12', '#27ae60', '#16a085',
|
||||
'#2980b9', '#8e44ad', '#2c3e50', '#7f8c8d', '#bdc3c7'
|
||||
];
|
||||
|
||||
// 10 Default guest avatars
|
||||
var GUEST_AVATARS = [
|
||||
{
|
||||
id: 'guest_1',
|
||||
name: 'Guest Blue',
|
||||
base: {
|
||||
faceShape: 'round',
|
||||
skinTone: 'medium1',
|
||||
eyes: { shape: 'round', color: 'blue' },
|
||||
eyebrows: 'natural',
|
||||
nose: 'button',
|
||||
mouth: 'smile',
|
||||
ears: 'normal',
|
||||
hair: { style: 'short-messy', color: 'darkBrown' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'average' },
|
||||
clothing: { style: 'casual', shirtColor: '#3498db', pantsColor: '#2c3e50' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_2',
|
||||
name: 'Guest Red',
|
||||
base: {
|
||||
faceShape: 'oval',
|
||||
skinTone: 'light2',
|
||||
eyes: { shape: 'almond', color: 'brown' },
|
||||
eyebrows: 'arched',
|
||||
nose: 'pointed',
|
||||
mouth: 'neutral',
|
||||
ears: 'normal',
|
||||
hair: { style: 'long-straight', color: 'black' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'slim' },
|
||||
clothing: { style: 'casual', shirtColor: '#e74c3c', pantsColor: '#34495e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_3',
|
||||
name: 'Guest Green',
|
||||
base: {
|
||||
faceShape: 'square',
|
||||
skinTone: 'tan1',
|
||||
eyes: { shape: 'narrow', color: 'darkBrown' },
|
||||
eyebrows: 'thick',
|
||||
nose: 'wide',
|
||||
mouth: 'grin',
|
||||
ears: 'large',
|
||||
hair: { style: 'buzz', color: 'black' },
|
||||
facialHair: 'stubble',
|
||||
body: { type: 'athletic' },
|
||||
clothing: { style: 'sporty', shirtColor: '#2ecc71', pantsColor: '#2c3e50' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_4',
|
||||
name: 'Guest Purple',
|
||||
base: {
|
||||
faceShape: 'heart',
|
||||
skinTone: 'fair2',
|
||||
eyes: { shape: 'wide', color: 'green' },
|
||||
eyebrows: 'curved',
|
||||
nose: 'snub',
|
||||
mouth: 'small',
|
||||
ears: 'small',
|
||||
hair: { style: 'bob', color: 'auburn' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'average' },
|
||||
clothing: { style: 'casual', shirtColor: '#9b59b6', pantsColor: '#7f8c8d' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_5',
|
||||
name: 'Guest Orange',
|
||||
base: {
|
||||
faceShape: 'long',
|
||||
skinTone: 'brown1',
|
||||
eyes: { shape: 'oval', color: 'amber' },
|
||||
eyebrows: 'straight',
|
||||
nose: 'roman',
|
||||
mouth: 'full',
|
||||
ears: 'normal',
|
||||
hair: { style: 'afro', color: 'black' },
|
||||
facialHair: 'goatee',
|
||||
body: { type: 'tall' },
|
||||
clothing: { style: 'casual', shirtColor: '#e67e22', pantsColor: '#34495e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_6',
|
||||
name: 'Guest Teal',
|
||||
base: {
|
||||
faceShape: 'soft',
|
||||
skinTone: 'dark1',
|
||||
eyes: { shape: 'happy', color: 'darkBrown' },
|
||||
eyebrows: 'natural',
|
||||
nose: 'flat',
|
||||
mouth: 'wide',
|
||||
ears: 'round',
|
||||
hair: { style: 'dreads', color: 'black' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'stocky' },
|
||||
clothing: { style: 'casual', shirtColor: '#1abc9c', pantsColor: '#2c3e50' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_7',
|
||||
name: 'Guest Gray',
|
||||
base: {
|
||||
faceShape: 'angular',
|
||||
skinTone: 'light3',
|
||||
eyes: { shape: 'cat', color: 'gray' },
|
||||
eyebrows: 'sleek',
|
||||
nose: 'narrow',
|
||||
mouth: 'smirk',
|
||||
ears: 'pointed',
|
||||
hair: { style: 'undercut', color: 'platinum' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'slim' },
|
||||
clothing: { style: 'scifi', shirtColor: '#95a5a6', pantsColor: '#34495e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_8',
|
||||
name: 'Guest Yellow',
|
||||
base: {
|
||||
faceShape: 'wide',
|
||||
skinTone: 'medium3',
|
||||
eyes: { shape: 'surprised', color: 'hazel' },
|
||||
eyebrows: 'worried',
|
||||
nose: 'button',
|
||||
mouth: 'open',
|
||||
ears: 'normal',
|
||||
hair: { style: 'spiky', color: 'blonde' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'short' },
|
||||
clothing: { style: 'casual', shirtColor: '#f1c40f', pantsColor: '#7f8c8d' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_9',
|
||||
name: 'Guest Navy',
|
||||
base: {
|
||||
faceShape: 'oval',
|
||||
skinTone: 'tan2',
|
||||
eyes: { shape: 'droopy', color: 'brown' },
|
||||
eyebrows: 'bushy',
|
||||
nose: 'aquiline',
|
||||
mouth: 'neutral',
|
||||
ears: 'flat',
|
||||
hair: { style: 'combover', color: 'gray' },
|
||||
facialHair: 'fullBeard',
|
||||
body: { type: 'stocky' },
|
||||
clothing: { style: 'formal', shirtColor: '#2c3e50', pantsColor: '#1a1a2e' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'guest_10',
|
||||
name: 'Guest Pink',
|
||||
base: {
|
||||
faceShape: 'round',
|
||||
skinTone: 'fair3',
|
||||
eyes: { shape: 'round', color: 'lightBlue' },
|
||||
eyebrows: 'thin',
|
||||
nose: 'snub',
|
||||
mouth: 'pout',
|
||||
ears: 'small',
|
||||
hair: { style: 'pigtails', color: 'strawberry' },
|
||||
facialHair: 'none',
|
||||
body: { type: 'average' },
|
||||
clothing: { style: 'casual', shirtColor: '#ff69b4', pantsColor: '#bdc3c7' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Helper function to find item by ID
|
||||
function findById(array, id) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
if (array[i].id === id) {
|
||||
return array[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Public API
|
||||
window.AvatarData = {
|
||||
FACE_SHAPES: FACE_SHAPES,
|
||||
SKIN_TONES: SKIN_TONES,
|
||||
EYE_SHAPES: EYE_SHAPES,
|
||||
EYE_COLORS: EYE_COLORS,
|
||||
EYEBROW_SHAPES: EYEBROW_SHAPES,
|
||||
NOSE_SHAPES: NOSE_SHAPES,
|
||||
MOUTH_SHAPES: MOUTH_SHAPES,
|
||||
EAR_SHAPES: EAR_SHAPES,
|
||||
HAIR_STYLES: HAIR_STYLES,
|
||||
HAIR_COLORS: HAIR_COLORS,
|
||||
FACIAL_HAIR: FACIAL_HAIR,
|
||||
BODY_TYPES: BODY_TYPES,
|
||||
CLOTHING_STYLES: CLOTHING_STYLES,
|
||||
CLOTHING_COLORS: CLOTHING_COLORS,
|
||||
GUEST_AVATARS: GUEST_AVATARS,
|
||||
|
||||
// Helper methods
|
||||
getFaceShape: function(id) { return findById(FACE_SHAPES, id); },
|
||||
getSkinTone: function(id) { return findById(SKIN_TONES, id); },
|
||||
getEyeShape: function(id) { return findById(EYE_SHAPES, id); },
|
||||
getEyeColor: function(id) { return findById(EYE_COLORS, id); },
|
||||
getEyebrowShape: function(id) { return findById(EYEBROW_SHAPES, id); },
|
||||
getNoseShape: function(id) { return findById(NOSE_SHAPES, id); },
|
||||
getMouthShape: function(id) { return findById(MOUTH_SHAPES, id); },
|
||||
getEarShape: function(id) { return findById(EAR_SHAPES, id); },
|
||||
getHairStyle: function(id) { return findById(HAIR_STYLES, id); },
|
||||
getHairColor: function(id) { return findById(HAIR_COLORS, id); },
|
||||
getFacialHair: function(id) { return findById(FACIAL_HAIR, id); },
|
||||
getBodyType: function(id) { return findById(BODY_TYPES, id); },
|
||||
getClothingStyle: function(id) { return findById(CLOTHING_STYLES, id); },
|
||||
|
||||
getRandomGuestAvatar: function() {
|
||||
return GUEST_AVATARS[Math.floor(Math.random() * GUEST_AVATARS.length)];
|
||||
},
|
||||
|
||||
createDefaultAvatar: function() {
|
||||
return {
|
||||
faceShape: 'round',
|
||||
skinTone: 'medium1',
|
||||
eyes: {
|
||||
shape: 'round',
|
||||
color: 'brown'
|
||||
},
|
||||
eyebrows: 'natural',
|
||||
nose: 'button',
|
||||
mouth: 'smile',
|
||||
ears: 'normal',
|
||||
hair: {
|
||||
style: 'short-neat',
|
||||
color: 'brown'
|
||||
},
|
||||
facialHair: 'none',
|
||||
body: {
|
||||
type: 'average'
|
||||
},
|
||||
clothing: {
|
||||
style: 'casual',
|
||||
shirtColor: '#3498db',
|
||||
pantsColor: '#2c3e50'
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* 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']
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,262 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// =========================================================================
|
||||
// AGGREGATE ALL CONTEXTS
|
||||
// =========================================================================
|
||||
|
||||
var ALL_CONTEXTS = [];
|
||||
|
||||
// Add vehicle contexts
|
||||
if (window.VehicleContexts) {
|
||||
VehicleContexts.getAll().forEach(function(ctx) {
|
||||
ALL_CONTEXTS.push(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Add costume contexts
|
||||
if (window.CostumeContexts) {
|
||||
CostumeContexts.getAll().forEach(function(ctx) {
|
||||
ALL_CONTEXTS.push(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Add pure contexts
|
||||
if (window.PureContexts) {
|
||||
PureContexts.getAll().forEach(function(ctx) {
|
||||
ALL_CONTEXTS.push(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Create lookup map
|
||||
var CONTEXT_MAP = {};
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
CONTEXT_MAP[ctx.id] = ctx;
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// CATEGORY METADATA
|
||||
// =========================================================================
|
||||
|
||||
var CATEGORIES = {
|
||||
vehicle: {
|
||||
id: 'vehicle',
|
||||
name: 'Vehicles',
|
||||
description: 'Place your avatar in vehicles and cockpits',
|
||||
icon: '🚗',
|
||||
count: 0 // calculated
|
||||
},
|
||||
costume: {
|
||||
id: 'costume',
|
||||
name: 'Costumes',
|
||||
description: 'Dress your avatar in themed outfits',
|
||||
icon: '👔',
|
||||
count: 0
|
||||
},
|
||||
pure: {
|
||||
id: 'pure',
|
||||
name: 'Standard',
|
||||
description: 'Your avatar with minimal modifications',
|
||||
icon: '🏃',
|
||||
count: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Count per category
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
if (CATEGORIES[ctx.category]) {
|
||||
CATEGORIES[ctx.category].count++;
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// GAME TYPE MAPPINGS
|
||||
// =========================================================================
|
||||
|
||||
var GAME_TYPE_CONTEXTS = {
|
||||
platformer: ['platformer-standard', 'adventure-hero', 'knight-armor', 'ninja-outfit', 'superhero-cape'],
|
||||
racing: ['race-car', 'motorcycle', 'hoverboard', 'airplane'],
|
||||
shooter: ['spaceship-cockpit', 'tank', 'mech-suit', 'space-suit'],
|
||||
flying: ['flappy-style', 'airplane', 'jetpack', 'dragon-rider'],
|
||||
arcade: ['pacman-style', 'flappy-style', 'spaceship-cockpit', 'runner'],
|
||||
rpg: ['knight-armor', 'wizard-robes', 'ninja-outfit', 'pirate-outfit', 'adventure-hero'],
|
||||
sports: ['sports-player', 'athlete-uniform', 'runner'],
|
||||
puzzle: ['scientist-labcoat', 'wizard-robes', 'platformer-standard'],
|
||||
adventure: ['adventure-hero', 'pirate-outfit', 'cowboy-gear', 'dragon-rider'],
|
||||
action: ['superhero-cape', 'ninja-outfit', 'mech-suit', 'jetpack'],
|
||||
underwater: ['submarine', 'space-suit'],
|
||||
space: ['spaceship-cockpit', 'space-suit', 'mech-suit'],
|
||||
cooking: ['chef-outfit'],
|
||||
simulation: ['airplane', 'submarine', 'race-car', 'tank']
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// MODE GROUPINGS
|
||||
// =========================================================================
|
||||
|
||||
var MODE_CONTEXTS = {
|
||||
HEAD_ONLY: [],
|
||||
HEAD_AND_SHOULDERS: [],
|
||||
UPPER_BODY: [],
|
||||
FULL_BODY: []
|
||||
};
|
||||
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
if (MODE_CONTEXTS[ctx.avatarMode]) {
|
||||
MODE_CONTEXTS[ctx.avatarMode].push(ctx.id);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// CATALOG STATISTICS
|
||||
// =========================================================================
|
||||
|
||||
var STATS = {
|
||||
totalContexts: ALL_CONTEXTS.length,
|
||||
vehicleCount: CATEGORIES.vehicle.count,
|
||||
costumeCount: CATEGORIES.costume.count,
|
||||
pureCount: CATEGORIES.pure.count,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2026-02-05'
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// PUBLIC API
|
||||
// =========================================================================
|
||||
|
||||
window.ContextCatalog = {
|
||||
// Core getters
|
||||
getAll: function() {
|
||||
return ALL_CONTEXTS.slice();
|
||||
},
|
||||
|
||||
getById: function(id) {
|
||||
return CONTEXT_MAP[id] || null;
|
||||
},
|
||||
|
||||
// Category queries
|
||||
getByCategory: function(category) {
|
||||
return ALL_CONTEXTS.filter(function(ctx) {
|
||||
return ctx.category === category;
|
||||
});
|
||||
},
|
||||
|
||||
getCategories: function() {
|
||||
return Object.values(CATEGORIES);
|
||||
},
|
||||
|
||||
getCategoryInfo: function(categoryId) {
|
||||
return CATEGORIES[categoryId] || null;
|
||||
},
|
||||
|
||||
// Mode queries
|
||||
getByMode: function(mode) {
|
||||
var ids = MODE_CONTEXTS[mode] || [];
|
||||
return ids.map(function(id) {
|
||||
return CONTEXT_MAP[id];
|
||||
}).filter(Boolean);
|
||||
},
|
||||
|
||||
getModes: function() {
|
||||
return Object.keys(MODE_CONTEXTS);
|
||||
},
|
||||
|
||||
// Game type recommendations
|
||||
getRecommendedFor: function(gameType) {
|
||||
var ids = GAME_TYPE_CONTEXTS[gameType] || [];
|
||||
return ids.map(function(id) {
|
||||
return CONTEXT_MAP[id];
|
||||
}).filter(Boolean);
|
||||
},
|
||||
|
||||
getGameTypes: function() {
|
||||
return Object.keys(GAME_TYPE_CONTEXTS);
|
||||
},
|
||||
|
||||
// Search/filter
|
||||
search: function(query) {
|
||||
query = query.toLowerCase();
|
||||
return ALL_CONTEXTS.filter(function(ctx) {
|
||||
return ctx.id.toLowerCase().includes(query) ||
|
||||
ctx.name.toLowerCase().includes(query) ||
|
||||
ctx.description.toLowerCase().includes(query);
|
||||
});
|
||||
},
|
||||
|
||||
filter: function(filters) {
|
||||
return ALL_CONTEXTS.filter(function(ctx) {
|
||||
if (filters.category && ctx.category !== filters.category) return false;
|
||||
if (filters.mode && ctx.avatarMode !== filters.mode) return false;
|
||||
if (filters.gameType) {
|
||||
var recommended = GAME_TYPE_CONTEXTS[filters.gameType] || [];
|
||||
if (recommended.indexOf(ctx.id) === -1) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// For UI display
|
||||
getCatalogForUI: function() {
|
||||
return {
|
||||
categories: Object.values(CATEGORIES).map(function(cat) {
|
||||
return {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
contexts: ALL_CONTEXTS.filter(function(ctx) {
|
||||
return ctx.category === cat.id;
|
||||
}).map(function(ctx) {
|
||||
return {
|
||||
id: ctx.id,
|
||||
name: ctx.name,
|
||||
description: ctx.description,
|
||||
mode: ctx.avatarMode,
|
||||
recommendedFor: ctx.recommendedFor
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
// Random selection
|
||||
getRandom: function(filters) {
|
||||
var pool = filters ? this.filter(filters) : ALL_CONTEXTS;
|
||||
if (pool.length === 0) return null;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
},
|
||||
|
||||
getRandomForGameType: function(gameType) {
|
||||
var recommended = this.getRecommendedFor(gameType);
|
||||
if (recommended.length === 0) return null;
|
||||
return recommended[Math.floor(Math.random() * recommended.length)];
|
||||
},
|
||||
|
||||
// Statistics
|
||||
getStats: function() {
|
||||
return Object.assign({}, STATS);
|
||||
},
|
||||
|
||||
// Validation
|
||||
validate: function() {
|
||||
var issues = [];
|
||||
|
||||
ALL_CONTEXTS.forEach(function(ctx) {
|
||||
if (!ctx.id) issues.push('Context missing id');
|
||||
if (!ctx.name) issues.push(ctx.id + ': missing name');
|
||||
if (!ctx.avatarMode) issues.push(ctx.id + ': missing avatarMode');
|
||||
if (!ctx.category) issues.push(ctx.id + ': missing category');
|
||||
if (!ctx.animations || ctx.animations.length === 0) {
|
||||
issues.push(ctx.id + ': missing animations');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
valid: issues.length === 0,
|
||||
issues: issues
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,959 @@
|
||||
/**
|
||||
* ContextRenderer.js - Main renderer for applying contexts to avatars
|
||||
*
|
||||
* Handles the complete rendering pipeline for avatars with contexts,
|
||||
* including vehicles, costumes, and pure animation contexts.
|
||||
*
|
||||
* Dependencies (must be loaded before this file):
|
||||
* - window.AvatarSystem
|
||||
* - window.AvatarRenderer
|
||||
* - window.AvatarAnimations
|
||||
* - window.AccessoryRenderer
|
||||
* - window.VehicleContexts
|
||||
* - window.CostumeContexts
|
||||
* - window.PureContexts
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================================
|
||||
// CONTEXT APPLICATION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Apply a context to an avatar, creating a renderable object
|
||||
* @param {Object} avatar - The avatar object
|
||||
* @param {string} contextId - The context ID to apply
|
||||
* @param {Object} options - Optional configuration
|
||||
* @returns {Object} Avatar with context object
|
||||
*/
|
||||
function apply(avatar, contextId, options) {
|
||||
var context = getContextById(contextId);
|
||||
|
||||
if (!context) {
|
||||
console.error('ContextRenderer: Unknown context ID:', contextId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate avatar has required mode or can be rendered in it
|
||||
var mode = context.avatarMode;
|
||||
if (!mode) {
|
||||
console.warn('ContextRenderer: Context missing avatarMode:', contextId);
|
||||
mode = 'profile'; // Default fallback
|
||||
}
|
||||
|
||||
return {
|
||||
avatar: avatar,
|
||||
context: context,
|
||||
mode: mode,
|
||||
state: {
|
||||
animation: context.defaultAnimation || 'idle',
|
||||
frame: 0,
|
||||
time: 0,
|
||||
animationStartTime: 0,
|
||||
animationOptions: {},
|
||||
effects: [],
|
||||
props: {},
|
||||
custom: {}
|
||||
},
|
||||
options: options || {}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN RENDER FUNCTION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render complete scene with avatar and context
|
||||
* @param {Object} avatarWithContext - The avatar+context object from apply()
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
||||
* @param {number} x - Center X position
|
||||
* @param {number} y - Center Y position
|
||||
* @param {Object} options - Render options (scale, time, etc)
|
||||
*/
|
||||
function render(avatarWithContext, ctx, x, y, options) {
|
||||
if (!avatarWithContext || !avatarWithContext.context) {
|
||||
console.error('ContextRenderer.render: Invalid avatarWithContext');
|
||||
return;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
var scale = options.scale || 1;
|
||||
var time = options.time || 0;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var context = awc.context;
|
||||
var avatar = awc.avatar;
|
||||
var mode = awc.mode;
|
||||
|
||||
// Get context dimensions
|
||||
var contextWidth = context.width || 64;
|
||||
var contextHeight = context.height || 64;
|
||||
var scaledWidth = contextWidth * scale;
|
||||
var scaledHeight = contextHeight * scale;
|
||||
|
||||
// Get animation transforms
|
||||
var transforms = {};
|
||||
if (awc.state.animation && window.AvatarAnimations) {
|
||||
var anim = window.AvatarAnimations.getAnimation(mode, awc.state.animation);
|
||||
if (anim) {
|
||||
var progress = anim.frameCount > 0 ? awc.state.frame / anim.frameCount : 0;
|
||||
transforms = window.AvatarAnimations.interpolateFrame(anim, progress);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate avatar position within context
|
||||
var avatarPosX = context.avatarPosition ? context.avatarPosition.x : 0.5;
|
||||
var avatarPosY = context.avatarPosition ? context.avatarPosition.y : 0.5;
|
||||
var avatarX = x + (avatarPosX - 0.5) * scaledWidth;
|
||||
var avatarY = y + (avatarPosY - 0.5) * scaledHeight;
|
||||
var avatarScale = scale * (context.avatarScale || 1);
|
||||
|
||||
// Route to appropriate render pipeline based on category
|
||||
var category = context.category || 'pure';
|
||||
|
||||
if (category === 'vehicle') {
|
||||
renderVehicleContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
|
||||
} else if (category === 'costume') {
|
||||
renderCostumeContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
|
||||
} else {
|
||||
// Pure context (default)
|
||||
renderPureContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, scaledWidth, scaledHeight, transforms, time);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VEHICLE CONTEXT RENDERING
|
||||
// ============================================================
|
||||
|
||||
function renderVehicleContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, width, height, transforms, time) {
|
||||
// 1. Context background (vehicle body behind avatar)
|
||||
if (context.renderBackground && typeof context.renderBackground === 'function') {
|
||||
ctx.save();
|
||||
context.renderBackground(ctx, width, height, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 2. Render avatar
|
||||
if (window.AvatarRenderer && window.AvatarRenderer.render) {
|
||||
window.AvatarRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, {
|
||||
scale: avatarScale,
|
||||
transforms: transforms
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Render accessories respecting visibility rules
|
||||
renderAccessoriesForContext(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
|
||||
|
||||
// 4. Context foreground (steering wheel, windshield, etc)
|
||||
if (context.renderForeground && typeof context.renderForeground === 'function') {
|
||||
ctx.save();
|
||||
context.renderForeground(ctx, width, height, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 5. Context effects (exhaust, speed lines, etc)
|
||||
renderContextEffects(ctx, awc, x, y, scale, time);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COSTUME CONTEXT RENDERING
|
||||
// ============================================================
|
||||
|
||||
function renderCostumeContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, width, height, transforms, time) {
|
||||
// 1. Costume under-layer (body armor, suit body, etc)
|
||||
if (context.renderCostumeUnder && typeof context.renderCostumeUnder === 'function') {
|
||||
ctx.save();
|
||||
context.renderCostumeUnder(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 2. Avatar HEAD ONLY (body is covered by costume)
|
||||
renderAvatarHeadOnly(ctx, avatar, avatarX, avatarY, avatarScale, transforms);
|
||||
|
||||
// 3. Costume over-layer (helmet, held items, cape)
|
||||
if (context.renderCostumeOver && typeof context.renderCostumeOver === 'function') {
|
||||
ctx.save();
|
||||
context.renderCostumeOver(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 4. EARNED ACCESSORIES ON TOP (crown shows above helmet, etc)
|
||||
renderEarnedAccessoriesOnTop(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
|
||||
|
||||
// 5. Effects (auras are always visible)
|
||||
renderEffectsForContext(ctx, avatar, context, avatarX, avatarY, avatarScale, time);
|
||||
|
||||
// 6. Context-specific effects
|
||||
renderContextEffects(ctx, awc, x, y, scale, time);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PURE CONTEXT RENDERING
|
||||
// ============================================================
|
||||
|
||||
function renderPureContext(ctx, awc, avatar, context, x, y, avatarX, avatarY,
|
||||
scale, avatarScale, width, height, transforms, time) {
|
||||
// 1. Behind effects (shadows, dust clouds, etc)
|
||||
if (context.renderEffectsBehind && typeof context.renderEffectsBehind === 'function') {
|
||||
ctx.save();
|
||||
context.renderEffectsBehind(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 2. Full avatar with all accessories
|
||||
if (window.AvatarRenderer && window.AvatarRenderer.render) {
|
||||
window.AvatarRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, {
|
||||
scale: avatarScale,
|
||||
transforms: transforms
|
||||
});
|
||||
}
|
||||
|
||||
if (window.AccessoryRenderer && window.AccessoryRenderer.render) {
|
||||
window.AccessoryRenderer.render(ctx, avatar, awc.mode, avatarX, avatarY, avatarScale, time);
|
||||
}
|
||||
|
||||
// 3. Over effects (speed lines, stars, sparkles)
|
||||
if (context.renderEffectsOver && typeof context.renderEffectsOver === 'function') {
|
||||
ctx.save();
|
||||
context.renderEffectsOver(ctx, avatar, avatarX, avatarY, avatarScale, transforms, time, awc.state);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 4. Active state effects
|
||||
renderContextEffects(ctx, awc, x, y, scale, time);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ANIMATION CONTROL
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Start an animation on an avatar with context
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {string} animationId - Animation ID to play
|
||||
* @param {Object} options - Animation options
|
||||
* @returns {boolean} True if animation started successfully
|
||||
*/
|
||||
function animate(avatarWithContext, animationId, options) {
|
||||
if (!avatarWithContext || !avatarWithContext.context) {
|
||||
console.error('ContextRenderer.animate: Invalid avatarWithContext');
|
||||
return false;
|
||||
}
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var context = awc.context;
|
||||
|
||||
// Validate animation is available for this context
|
||||
if (context.animations && Array.isArray(context.animations)) {
|
||||
if (context.animations.indexOf(animationId) === -1) {
|
||||
console.warn('ContextRenderer: Animation not available for context:', animationId, 'in', context.id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if animation exists in the animation system
|
||||
if (window.AvatarAnimations) {
|
||||
var anim = window.AvatarAnimations.getAnimation(awc.mode, animationId);
|
||||
if (!anim) {
|
||||
console.warn('ContextRenderer: Animation not found:', animationId, 'for mode', awc.mode);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set animation state
|
||||
awc.state.animation = animationId;
|
||||
awc.state.frame = 0;
|
||||
awc.state.time = 0;
|
||||
awc.state.animationStartTime = performance.now();
|
||||
awc.state.animationOptions = options || {};
|
||||
|
||||
// Trigger context-specific effects for this animation
|
||||
if (context.contextEffects && context.contextEffects[animationId]) {
|
||||
triggerEffect(awc, context.contextEffects[animationId]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animation state based on elapsed time
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {number} deltaTime - Time elapsed in seconds
|
||||
*/
|
||||
function updateAnimation(avatarWithContext, deltaTime) {
|
||||
if (!avatarWithContext) return;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
|
||||
if (!awc.state.animation) return;
|
||||
|
||||
// Get animation definition
|
||||
var anim = null;
|
||||
if (window.AvatarAnimations) {
|
||||
anim = window.AvatarAnimations.getAnimation(awc.mode, awc.state.animation);
|
||||
}
|
||||
|
||||
if (!anim) return;
|
||||
|
||||
// Update time
|
||||
awc.state.time += deltaTime;
|
||||
|
||||
// Calculate frame based on time
|
||||
var frameDuration = (anim.frameDuration || 100) / 1000; // Convert ms to seconds
|
||||
var totalFrames = anim.frameCount || 1;
|
||||
|
||||
if (anim.loop) {
|
||||
// Looping animation
|
||||
awc.state.frame = (awc.state.time / frameDuration) % totalFrames;
|
||||
} else {
|
||||
// One-shot animation
|
||||
awc.state.frame = Math.min(awc.state.time / frameDuration, totalFrames - 1);
|
||||
|
||||
// Check if animation completed
|
||||
if (awc.state.frame >= totalFrames - 1) {
|
||||
// Trigger completion callback if set
|
||||
if (awc.state.animationOptions && awc.state.animationOptions.onComplete) {
|
||||
awc.state.animationOptions.onComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update active effects
|
||||
updateEffects(awc, deltaTime);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EFFECT SYSTEM
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Trigger a visual effect
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {Object} effectDef - Effect definition
|
||||
*/
|
||||
function triggerEffect(avatarWithContext, effectDef) {
|
||||
if (!avatarWithContext || !effectDef) return;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
|
||||
// Handle effect definition as string (lookup) or object
|
||||
var effect;
|
||||
if (typeof effectDef === 'string') {
|
||||
effect = {
|
||||
type: effectDef,
|
||||
color: '#ffffff',
|
||||
startTime: performance.now(),
|
||||
duration: 0.5,
|
||||
intensity: 1,
|
||||
data: {}
|
||||
};
|
||||
} else {
|
||||
effect = {
|
||||
type: effectDef.type || 'flash',
|
||||
color: effectDef.color || '#ffffff',
|
||||
startTime: performance.now(),
|
||||
duration: effectDef.duration || 0.5,
|
||||
intensity: effectDef.intensity || 1,
|
||||
data: effectDef.data || {}
|
||||
};
|
||||
}
|
||||
|
||||
awc.state.effects.push(effect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all active effects
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {number} deltaTime - Time elapsed in seconds
|
||||
*/
|
||||
function updateEffects(avatarWithContext, deltaTime) {
|
||||
if (!avatarWithContext) return;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var now = performance.now();
|
||||
|
||||
// Filter out expired effects and update active ones
|
||||
awc.state.effects = awc.state.effects.filter(function(effect) {
|
||||
var elapsed = (now - effect.startTime) / 1000;
|
||||
|
||||
if (elapsed >= effect.duration) {
|
||||
return false; // Remove expired effect
|
||||
}
|
||||
|
||||
// Update effect progress
|
||||
effect.progress = elapsed / effect.duration;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active effects
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
*/
|
||||
function clearEffects(avatarWithContext) {
|
||||
if (!avatarWithContext) return;
|
||||
avatarWithContext.state.effects = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render context effects
|
||||
*/
|
||||
function renderContextEffects(ctx, awc, x, y, scale, time) {
|
||||
if (!awc.state.effects || awc.state.effects.length === 0) return;
|
||||
|
||||
var now = performance.now();
|
||||
|
||||
awc.state.effects.forEach(function(effect) {
|
||||
var elapsed = (now - effect.startTime) / 1000;
|
||||
var progress = Math.min(elapsed / effect.duration, 1);
|
||||
|
||||
ctx.save();
|
||||
|
||||
switch (effect.type) {
|
||||
case 'flash':
|
||||
renderFlashEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'burst':
|
||||
renderBurstEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'trail':
|
||||
renderTrailEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'glow':
|
||||
renderGlowEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
case 'particles':
|
||||
renderParticleEffect(ctx, x, y, scale, effect, progress, time);
|
||||
break;
|
||||
case 'shake':
|
||||
// Shake is handled via transforms, not direct rendering
|
||||
break;
|
||||
case 'speedLines':
|
||||
renderSpeedLinesEffect(ctx, x, y, scale, effect, progress);
|
||||
break;
|
||||
default:
|
||||
// Unknown effect type - custom effects can be rendered by context
|
||||
break;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
// Effect renderers
|
||||
function renderFlashEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = (1 - progress) * effect.intensity;
|
||||
var radius = 30 * scale * (1 + progress);
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = effect.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function renderBurstEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = (1 - progress) * effect.intensity;
|
||||
var rayCount = effect.data.rayCount || 8;
|
||||
var maxLength = (effect.data.maxLength || 40) * scale;
|
||||
var length = maxLength * progress;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = effect.color;
|
||||
ctx.lineWidth = 2 * scale;
|
||||
|
||||
for (var i = 0; i < rayCount; i++) {
|
||||
var angle = (i / rayCount) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(
|
||||
x + Math.cos(angle) * length,
|
||||
y + Math.sin(angle) * length
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrailEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = (1 - progress) * effect.intensity * 0.5;
|
||||
var trailLength = (effect.data.length || 20) * scale;
|
||||
var direction = effect.data.direction || -1; // -1 = trailing left
|
||||
|
||||
var gradient = ctx.createLinearGradient(
|
||||
x, y,
|
||||
x + trailLength * direction, y
|
||||
);
|
||||
gradient.addColorStop(0, effect.color);
|
||||
gradient.addColorStop(1, 'transparent');
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x, y - 10 * scale, trailLength * direction, 20 * scale);
|
||||
}
|
||||
|
||||
function renderGlowEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = effect.intensity * (0.5 + 0.5 * Math.sin(progress * Math.PI * 2));
|
||||
var radius = (effect.data.radius || 25) * scale;
|
||||
|
||||
var gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, effect.color);
|
||||
gradient.addColorStop(1, 'transparent');
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function renderParticleEffect(ctx, x, y, scale, effect, progress, time) {
|
||||
var particleCount = effect.data.count || 10;
|
||||
var alpha = (1 - progress) * effect.intensity;
|
||||
var spread = (effect.data.spread || 30) * scale;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = effect.color;
|
||||
|
||||
// Use seeded random based on effect start time for consistency
|
||||
var seed = effect.startTime;
|
||||
|
||||
for (var i = 0; i < particleCount; i++) {
|
||||
var pseudoRandom = Math.sin(seed + i * 12.9898) * 43758.5453;
|
||||
pseudoRandom = pseudoRandom - Math.floor(pseudoRandom);
|
||||
|
||||
var angle = pseudoRandom * Math.PI * 2;
|
||||
var distance = spread * progress * (0.5 + pseudoRandom * 0.5);
|
||||
var size = 2 * scale * (1 - progress);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
x + Math.cos(angle) * distance,
|
||||
y + Math.sin(angle) * distance - progress * 20 * scale, // Rise up
|
||||
size,
|
||||
0, Math.PI * 2
|
||||
);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpeedLinesEffect(ctx, x, y, scale, effect, progress) {
|
||||
var alpha = effect.intensity * (1 - progress * 0.5);
|
||||
var lineCount = effect.data.lineCount || 5;
|
||||
var lineLength = (effect.data.lineLength || 30) * scale;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = effect.color;
|
||||
ctx.lineWidth = 1 * scale;
|
||||
|
||||
for (var i = 0; i < lineCount; i++) {
|
||||
var yOffset = (i - lineCount / 2) * 8 * scale;
|
||||
var xStart = x - 20 * scale - lineLength * progress;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xStart, y + yOffset);
|
||||
ctx.lineTo(xStart + lineLength, y + yOffset);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER RENDERING FUNCTIONS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Render only the avatar's head (for costumes that cover body)
|
||||
*/
|
||||
function renderAvatarHeadOnly(ctx, avatar, x, y, scale, transforms) {
|
||||
if (window.AvatarRenderer && window.AvatarRenderer.renderHead) {
|
||||
window.AvatarRenderer.renderHead(ctx, avatar, x, y, {
|
||||
scale: scale,
|
||||
transforms: transforms
|
||||
});
|
||||
} else if (window.AvatarRenderer && window.AvatarRenderer.render) {
|
||||
// Fallback: render full avatar if renderHead not available
|
||||
// This is a compromise - costume should hide body anyway
|
||||
ctx.save();
|
||||
|
||||
// Clip to head region (approximate)
|
||||
var headY = y - 12 * scale; // Head is above center
|
||||
var headRadius = 10 * scale;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, headY, headRadius * 1.5, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
|
||||
window.AvatarRenderer.render(ctx, avatar, 'profile', x, y, {
|
||||
scale: scale,
|
||||
transforms: transforms
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render accessories respecting context visibility rules
|
||||
*/
|
||||
function renderAccessoriesForContext(ctx, avatar, context, x, y, scale, time) {
|
||||
if (!window.AccessoryRenderer) return;
|
||||
|
||||
var vis = context.accessoryVisibility || {};
|
||||
|
||||
// Default visibility if not specified
|
||||
var showHeadwear = vis.headwear !== false;
|
||||
var showHandheld = vis.handheld !== false;
|
||||
var showEffects = vis.effects !== false;
|
||||
var showFace = vis.face !== false;
|
||||
|
||||
// Use AccessoryRenderer with visibility filter
|
||||
if (window.AccessoryRenderer.renderFiltered) {
|
||||
window.AccessoryRenderer.renderFiltered(ctx, avatar, context.avatarMode || 'profile', x, y, scale, time, {
|
||||
headwear: showHeadwear,
|
||||
handheld: showHandheld,
|
||||
effects: showEffects,
|
||||
face: showFace
|
||||
});
|
||||
} else if (window.AccessoryRenderer.render) {
|
||||
// Fallback to standard render if filtered not available
|
||||
// Context-specific visibility would need to be handled differently
|
||||
window.AccessoryRenderer.render(ctx, avatar, context.avatarMode || 'profile', x, y, scale, time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render EARNED accessories on top of everything (crowns above helmets, etc)
|
||||
*/
|
||||
function renderEarnedAccessoriesOnTop(ctx, avatar, context, x, y, scale, time) {
|
||||
if (!window.AccessoryRenderer) return;
|
||||
if (!avatar.accessories) return;
|
||||
|
||||
var vis = context.accessoryVisibility || {};
|
||||
var costumeOffset = context.costumeHeadOffset || { x: 0, y: -5 }; // Crown sits higher on helmet
|
||||
|
||||
// Render headwear if visibility allows and avatar has it
|
||||
if (vis.headwear === true && avatar.accessories.headwear) {
|
||||
var headwearY = y + costumeOffset.y * scale;
|
||||
var headwearX = x + costumeOffset.x * scale;
|
||||
|
||||
if (window.AccessoryRenderer.renderSingle) {
|
||||
window.AccessoryRenderer.renderSingle(ctx, avatar, 'headwear', headwearX, headwearY, scale, time);
|
||||
}
|
||||
}
|
||||
|
||||
// Render effect accessories (auras, trails) if allowed
|
||||
if (vis.effects === true && avatar.accessories.effect) {
|
||||
if (window.AccessoryRenderer.renderSingle) {
|
||||
window.AccessoryRenderer.renderSingle(ctx, avatar, 'effect', x, y, scale, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render effect accessories (auras, etc) for costume context
|
||||
*/
|
||||
function renderEffectsForContext(ctx, avatar, context, x, y, scale, time) {
|
||||
if (!window.AccessoryRenderer) return;
|
||||
if (!avatar.accessories || !avatar.accessories.effect) return;
|
||||
|
||||
var vis = context.accessoryVisibility || {};
|
||||
|
||||
// Effects (auras) are almost always visible
|
||||
if (vis.effects !== false) {
|
||||
if (window.AccessoryRenderer.renderSingle) {
|
||||
window.AccessoryRenderer.renderSingle(ctx, avatar, 'effect', x, y, scale, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONTEXT LOOKUP
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get context definition by ID
|
||||
*/
|
||||
function getContextById(contextId) {
|
||||
// Check ContextCatalog first
|
||||
if (window.ContextCatalog && window.ContextCatalog.getById) {
|
||||
var context = window.ContextCatalog.getById(contextId);
|
||||
if (context) return context;
|
||||
}
|
||||
|
||||
// Fallback to individual context modules
|
||||
if (window.VehicleContexts && window.VehicleContexts[contextId]) {
|
||||
return window.VehicleContexts[contextId];
|
||||
}
|
||||
if (window.CostumeContexts && window.CostumeContexts[contextId]) {
|
||||
return window.CostumeContexts[contextId];
|
||||
}
|
||||
if (window.PureContexts && window.PureContexts[contextId]) {
|
||||
return window.PureContexts[contextId];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required avatar mode for a context
|
||||
*/
|
||||
function getRequiredMode(contextId) {
|
||||
var context = getContextById(contextId);
|
||||
return context ? context.avatarMode : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available animations for a context
|
||||
*/
|
||||
function getAvailableAnimations(contextId) {
|
||||
var context = getContextById(contextId);
|
||||
return context && context.animations ? context.animations.slice() : [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STATE MANAGEMENT
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Set custom state value
|
||||
*/
|
||||
function setState(avatarWithContext, key, value) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return;
|
||||
avatarWithContext.state.custom[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom state value
|
||||
*/
|
||||
function getState(avatarWithContext, key) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return undefined;
|
||||
return avatarWithContext.state.custom[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set prop state (for interactive elements)
|
||||
*/
|
||||
function setProp(avatarWithContext, propId, state) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return;
|
||||
avatarWithContext.state.props[propId] = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prop state
|
||||
*/
|
||||
function getProp(avatarWithContext, propId) {
|
||||
if (!avatarWithContext || !avatarWithContext.state) return undefined;
|
||||
return avatarWithContext.state.props[propId];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXPORT UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Create a sprite sheet from avatar with context
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @param {Array} animations - Array of animation IDs to include
|
||||
* @param {Object} options - Sprite sheet options
|
||||
* @returns {Object} Sprite sheet data { canvas, frames, meta }
|
||||
*/
|
||||
function createSpriteSheet(avatarWithContext, animations, options) {
|
||||
if (!avatarWithContext) return null;
|
||||
|
||||
options = options || {};
|
||||
var frameWidth = options.frameWidth || 64;
|
||||
var frameHeight = options.frameHeight || 64;
|
||||
var scale = options.scale || 1;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var allFrames = [];
|
||||
|
||||
// Collect all frames for each animation
|
||||
animations.forEach(function(animId) {
|
||||
var anim = null;
|
||||
if (window.AvatarAnimations) {
|
||||
anim = window.AvatarAnimations.getAnimation(awc.mode, animId);
|
||||
}
|
||||
|
||||
if (!anim) return;
|
||||
|
||||
for (var i = 0; i < anim.frameCount; i++) {
|
||||
allFrames.push({
|
||||
animation: animId,
|
||||
frame: i,
|
||||
frameCount: anim.frameCount
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (allFrames.length === 0) return null;
|
||||
|
||||
// Calculate sprite sheet dimensions
|
||||
var cols = Math.ceil(Math.sqrt(allFrames.length));
|
||||
var rows = Math.ceil(allFrames.length / cols);
|
||||
var sheetWidth = cols * frameWidth;
|
||||
var sheetHeight = rows * frameHeight;
|
||||
|
||||
// Create canvas
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = sheetWidth;
|
||||
canvas.height = sheetHeight;
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
// Render each frame
|
||||
var frames = [];
|
||||
allFrames.forEach(function(frameData, index) {
|
||||
var col = index % cols;
|
||||
var row = Math.floor(index / cols);
|
||||
var x = col * frameWidth + frameWidth / 2;
|
||||
var y = row * frameHeight + frameHeight / 2;
|
||||
|
||||
// Temporarily set animation state
|
||||
awc.state.animation = frameData.animation;
|
||||
awc.state.frame = frameData.frame;
|
||||
|
||||
// Render
|
||||
render(awc, ctx, x, y, { scale: scale, time: 0 });
|
||||
|
||||
frames.push({
|
||||
animation: frameData.animation,
|
||||
frame: frameData.frame,
|
||||
x: col * frameWidth,
|
||||
y: row * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
canvas: canvas,
|
||||
frames: frames,
|
||||
meta: {
|
||||
width: sheetWidth,
|
||||
height: sheetHeight,
|
||||
frameWidth: frameWidth,
|
||||
frameHeight: frameHeight,
|
||||
animations: animations
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export avatar with context for game use
|
||||
* @param {Object} avatarWithContext - The avatar+context object
|
||||
* @returns {Object} Exportable data
|
||||
*/
|
||||
function exportForGame(avatarWithContext) {
|
||||
if (!avatarWithContext) return null;
|
||||
|
||||
var awc = avatarWithContext;
|
||||
var context = awc.context;
|
||||
|
||||
return {
|
||||
avatarId: awc.avatar.id || null,
|
||||
contextId: context.id,
|
||||
mode: awc.mode,
|
||||
category: context.category,
|
||||
animations: context.animations || [],
|
||||
defaultAnimation: context.defaultAnimation || 'idle',
|
||||
dimensions: {
|
||||
width: context.width || 64,
|
||||
height: context.height || 64
|
||||
},
|
||||
avatarPosition: context.avatarPosition || { x: 0.5, y: 0.5 },
|
||||
avatarScale: context.avatarScale || 1,
|
||||
accessoryVisibility: context.accessoryVisibility || {},
|
||||
customState: Object.assign({}, awc.state.custom)
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CATALOG ACCESS (delegates to ContextCatalog)
|
||||
// ============================================================
|
||||
|
||||
function getCatalog() {
|
||||
if (window.ContextCatalog && window.ContextCatalog.getAll) {
|
||||
return window.ContextCatalog.getAll();
|
||||
}
|
||||
|
||||
// Fallback: aggregate from individual modules
|
||||
var catalog = [];
|
||||
|
||||
if (window.VehicleContexts) {
|
||||
Object.keys(window.VehicleContexts).forEach(function(id) {
|
||||
if (typeof window.VehicleContexts[id] === 'object') {
|
||||
catalog.push(window.VehicleContexts[id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.CostumeContexts) {
|
||||
Object.keys(window.CostumeContexts).forEach(function(id) {
|
||||
if (typeof window.CostumeContexts[id] === 'object') {
|
||||
catalog.push(window.CostumeContexts[id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.PureContexts) {
|
||||
Object.keys(window.PureContexts).forEach(function(id) {
|
||||
if (typeof window.PureContexts[id] === 'object') {
|
||||
catalog.push(window.PureContexts[id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function getContext(id) {
|
||||
return getContextById(id);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUBLIC API
|
||||
// ============================================================
|
||||
|
||||
window.ContextRenderer = {
|
||||
// Core methods
|
||||
apply: apply,
|
||||
render: render,
|
||||
animate: animate,
|
||||
updateAnimation: updateAnimation,
|
||||
|
||||
// Effect control
|
||||
triggerEffect: triggerEffect,
|
||||
updateEffects: updateEffects,
|
||||
clearEffects: clearEffects,
|
||||
|
||||
// State management
|
||||
setState: setState,
|
||||
getState: getState,
|
||||
setProp: setProp,
|
||||
getProp: getProp,
|
||||
|
||||
// Context queries
|
||||
getRequiredMode: getRequiredMode,
|
||||
getAvailableAnimations: getAvailableAnimations,
|
||||
|
||||
// Catalog access (delegates to ContextCatalog)
|
||||
getCatalog: getCatalog,
|
||||
getContext: getContext,
|
||||
|
||||
// Utility
|
||||
createSpriteSheet: createSpriteSheet,
|
||||
exportForGame: exportForGame,
|
||||
|
||||
// Version
|
||||
version: '1.0.0'
|
||||
};
|
||||
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user