/** * Pure Contexts - Minimal modifications to avatar for standard gameplay * Uses FULL_BODY mode with the avatar fully visible */ (function() { 'use strict'; // ============================================ // SHARED EFFECT RENDERERS // ============================================ /** * Render dust puff effects * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X position * @param {number} y - Ground Y position * @param {number} scale - Avatar scale * @param {number} intensity - Effect intensity (0-1) * @param {number} time - Animation time in seconds * @param {Object} options - Additional options */ function renderDust(ctx, x, y, scale, intensity, time, options = {}) { const { color = 'rgba(139, 119, 101, 0.6)', particleCount = 5, spread = 20 * scale, riseSpeed = 30, fadeTime = 0.5 } = options; const progress = Math.min(time / fadeTime, 1); const alpha = (1 - progress) * intensity; if (alpha <= 0) return; ctx.save(); for (let i = 0; i < particleCount; i++) { const angle = (i / particleCount) * Math.PI + Math.random() * 0.2; const distance = spread * progress * (0.5 + Math.random() * 0.5); const px = x + Math.cos(angle) * distance; const py = y - Math.sin(angle) * distance * 0.5 - riseSpeed * progress * scale; const size = (8 + Math.random() * 6) * scale * (1 - progress * 0.5); ctx.beginPath(); ctx.arc(px, py, size, 0, Math.PI * 2); ctx.fillStyle = color.replace(/[\d.]+\)$/, (alpha * 0.6) + ')'); ctx.fill(); } ctx.restore(); } /** * Render speed lines behind avatar * @param {CanvasRenderingContext2D} ctx * @param {number} x - Avatar center X * @param {number} y - Avatar center Y * @param {number} scale - Avatar scale * @param {number} direction - Direction (-1 = left, 1 = right) * @param {number} intensity - Effect intensity (0-1) * @param {Object} options - Additional options */ function renderSpeedLines(ctx, x, y, scale, direction, intensity, options = {}) { const { lineCount = 8, maxLength = 60, color = 'rgba(255, 255, 255, 0.7)', spread = 80 } = options; if (intensity <= 0) return; ctx.save(); ctx.lineCap = 'round'; for (let i = 0; i < lineCount; i++) { const yOffset = (i - lineCount / 2) * (spread / lineCount) * scale; const lineY = y + yOffset; const lineLength = (maxLength * (0.4 + Math.random() * 0.6)) * scale * intensity; const lineWidth = (2 + Math.random() * 2) * scale; const startX = x - direction * 30 * scale; const alpha = intensity * (0.3 + Math.random() * 0.4); ctx.beginPath(); ctx.moveTo(startX, lineY); ctx.lineTo(startX - direction * lineLength, lineY); ctx.strokeStyle = color.replace(/[\d.]+\)$/, alpha + ')'); ctx.lineWidth = lineWidth; ctx.stroke(); } ctx.restore(); } /** * Render cartoon stars circling around head * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X (head position) * @param {number} y - Center Y (head position) * @param {number} scale - Avatar scale * @param {number} count - Number of stars * @param {number} time - Animation time * @param {Object} options - Additional options */ function renderStars(ctx, x, y, scale, count, time, options = {}) { const { radius = 25, starSize = 8, rotationSpeed = 3, color = '#FFD700', highlightColor = '#FFFFFF' } = options; ctx.save(); const orbitRadius = radius * scale; const size = starSize * scale; for (let i = 0; i < count; i++) { const angle = (time * rotationSpeed) + (i * Math.PI * 2 / count); const sx = x + Math.cos(angle) * orbitRadius; const sy = y + Math.sin(angle) * orbitRadius * 0.4; // Elliptical orbit // Draw 4-pointed star ctx.beginPath(); for (let j = 0; j < 8; j++) { const starAngle = (j * Math.PI / 4) + time * 2; const pointRadius = j % 2 === 0 ? size : size * 0.4; const px = sx + Math.cos(starAngle) * pointRadius; const py = sy + Math.sin(starAngle) * pointRadius; if (j === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.closePath(); ctx.fillStyle = color; ctx.fill(); // Highlight ctx.beginPath(); ctx.arc(sx - size * 0.2, sy - size * 0.2, size * 0.2, 0, Math.PI * 2); ctx.fillStyle = highlightColor; ctx.fill(); } ctx.restore(); } /** * Render celebratory confetti * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X * @param {number} y - Start Y * @param {number} scale - Avatar scale * @param {number} time - Animation time * @param {Object} options - Additional options */ function renderConfetti(ctx, x, y, scale, time, options = {}) { const { particleCount = 30, spread = 100, colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'], gravity = 150, initialVelocity = 200 } = options; if (time > 3) return; // Stop after 3 seconds ctx.save(); // Use seeded random for consistent particles const seed = 12345; const random = (i, offset = 0) => { const x = Math.sin(seed + i * 9999 + offset) * 10000; return x - Math.floor(x); }; for (let i = 0; i < particleCount; i++) { const angle = random(i, 0) * Math.PI - Math.PI / 2; const velocity = initialVelocity * (0.5 + random(i, 1) * 0.5) * scale; const vx = Math.cos(angle) * velocity * (random(i, 2) - 0.5) * 2; const vy = -Math.abs(Math.sin(angle) * velocity); const px = x + vx * time; const py = y + vy * time + 0.5 * gravity * time * time * scale; // Skip if fallen too far if (py > y + 200 * scale) continue; const rotation = time * (3 + random(i, 3) * 4) * (random(i, 4) > 0.5 ? 1 : -1); const width = (4 + random(i, 5) * 4) * scale; const height = (8 + random(i, 6) * 6) * scale; const color = colors[Math.floor(random(i, 7) * colors.length)]; ctx.save(); ctx.translate(px, py); ctx.rotate(rotation); ctx.fillStyle = color; ctx.fillRect(-width / 2, -height / 2, width, height); ctx.restore(); } ctx.restore(); } /** * Render shadow beneath avatar * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X * @param {number} y - Ground Y * @param {number} width - Shadow base width * @param {number} height - Shadow base height * @param {number} elevation - Avatar elevation (affects size) * @param {Object} options - Additional options */ function renderShadow(ctx, x, y, width, height, elevation, options = {}) { const { color = 'rgba(0, 0, 0, 0.3)', maxElevation = 100, minScale = 0.5 } = options; const elevationFactor = Math.max(0, 1 - elevation / maxElevation); const scale = minScale + (1 - minScale) * elevationFactor; const alpha = 0.3 * elevationFactor; if (alpha <= 0) return; ctx.save(); ctx.beginPath(); ctx.ellipse(x, y, width * scale / 2, height * scale / 2, 0, 0, Math.PI * 2); ctx.fillStyle = color.replace(/[\d.]+\)$/, alpha + ')'); ctx.fill(); ctx.restore(); } /** * Render sparkle/shine effect * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X * @param {number} y - Center Y * @param {number} scale - Effect scale * @param {number} time - Animation time * @param {Object} options - Additional options */ function renderSparkle(ctx, x, y, scale, time, options = {}) { const { count = 5, spread = 40, color = '#FFFFFF', maxSize = 6, twinkleSpeed = 4 } = options; ctx.save(); const seed = 54321; const random = (i, offset = 0) => { const v = Math.sin(seed + i * 7777 + offset) * 10000; return v - Math.floor(v); }; for (let i = 0; i < count; i++) { const px = x + (random(i, 0) - 0.5) * spread * 2 * scale; const py = y + (random(i, 1) - 0.5) * spread * 2 * scale; const phase = random(i, 2) * Math.PI * 2; const twinkle = Math.abs(Math.sin(time * twinkleSpeed + phase)); const size = maxSize * scale * twinkle; if (size < 0.5) continue; // Draw 4-pointed sparkle ctx.beginPath(); ctx.moveTo(px, py - size); ctx.lineTo(px + size * 0.3, py); ctx.lineTo(px, py + size); ctx.lineTo(px - size * 0.3, py); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); ctx.beginPath(); ctx.moveTo(px - size, py); ctx.lineTo(px, py + size * 0.3); ctx.lineTo(px + size, py); ctx.lineTo(px, py - size * 0.3); ctx.closePath(); ctx.fill(); } ctx.restore(); } /** * Render flash/impact effect * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X * @param {number} y - Center Y * @param {number} width - Flash width * @param {number} height - Flash height * @param {number} time - Animation time * @param {Object} options - Additional options */ function renderFlash(ctx, x, y, width, height, time, options = {}) { const { color = 'rgba(255, 255, 255, 0.8)', duration = 0.3, pulseCount = 2 } = options; const progress = time / duration; if (progress > 1) return; const pulse = Math.sin(progress * Math.PI * pulseCount); const alpha = pulse * (1 - progress); if (alpha <= 0) return; ctx.save(); ctx.beginPath(); ctx.ellipse(x, y, width / 2, height / 2, 0, 0, Math.PI * 2); ctx.fillStyle = color.replace(/[\d.]+\)$/, alpha + ')'); ctx.fill(); ctx.restore(); } /** * Render heroic glow effect * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X * @param {number} y - Center Y * @param {number} width - Glow width * @param {number} height - Glow height * @param {number} time - Animation time * @param {Object} options - Additional options */ function renderGlow(ctx, x, y, width, height, time, options = {}) { const { color = 'rgba(255, 215, 0, 0.15)', pulseSpeed = 2, pulseAmount = 0.2 } = options; const pulse = 1 + Math.sin(time * pulseSpeed) * pulseAmount; ctx.save(); const gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.max(width, height) * pulse / 2); gradient.addColorStop(0, color); gradient.addColorStop(1, 'rgba(255, 215, 0, 0)'); ctx.beginPath(); ctx.ellipse(x, y, width * pulse / 2, height * pulse / 2, 0, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); ctx.restore(); } /** * Render motion blur trail * @param {CanvasRenderingContext2D} ctx * @param {number} x - Current X * @param {number} y - Current Y * @param {number} scale - Avatar scale * @param {number} direction - Direction (-1 = left, 1 = right) * @param {number} intensity - Blur intensity * @param {Object} options - Additional options */ function renderMotionBlur(ctx, x, y, scale, direction, intensity, options = {}) { const { trailCount = 4, spacing = 15, color = 'rgba(200, 200, 255, 0.3)', width = 20, height = 60 } = options; if (intensity <= 0) return; ctx.save(); for (let i = 1; i <= trailCount; i++) { const alpha = (1 - i / (trailCount + 1)) * intensity * 0.3; const tx = x - direction * spacing * i * scale; ctx.beginPath(); ctx.ellipse(tx, y, width * scale / 2, height * scale / 2, 0, 0, Math.PI * 2); ctx.fillStyle = color.replace(/[\d.]+\)$/, alpha + ')'); ctx.fill(); } ctx.restore(); } /** * Render friction sparks * @param {CanvasRenderingContext2D} ctx * @param {number} x - Center X * @param {number} y - Ground Y * @param {number} scale - Effect scale * @param {number} direction - Direction of movement * @param {number} time - Animation time * @param {Object} options - Additional options */ function renderSparks(ctx, x, y, scale, direction, time, options = {}) { const { count = 8, color = '#FFA500', highlightColor = '#FFFF00', spread = 30 } = options; ctx.save(); const seed = 98765; const random = (i, offset = 0) => { const v = Math.sin(seed + i * 3333 + offset + time * 100) * 10000; return v - Math.floor(v); }; for (let i = 0; i < count; i++) { const lifetime = random(i, 0) * 0.3; const sparkTime = (time % 0.3); if (sparkTime > lifetime) continue; const angle = -direction * (Math.PI / 6 + random(i, 1) * Math.PI / 3); const velocity = (50 + random(i, 2) * 50) * scale; const px = x + Math.cos(angle) * velocity * sparkTime; const py = y + Math.sin(angle) * velocity * sparkTime - 20 * sparkTime * scale; const size = (2 + random(i, 3) * 2) * scale * (1 - sparkTime / lifetime); ctx.beginPath(); ctx.arc(px, py, size, 0, Math.PI * 2); ctx.fillStyle = random(i, 4) > 0.5 ? color : highlightColor; ctx.fill(); } ctx.restore(); } /** * Render small backpack * @param {CanvasRenderingContext2D} ctx * @param {number} x - Avatar center X * @param {number} y - Avatar center Y * @param {number} scale - Avatar scale * @param {boolean} facingRight - Avatar facing direction */ function renderBackpack(ctx, x, y, scale, facingRight) { const offsetX = facingRight ? -25 : 25; const bx = x + offsetX * scale; const by = y - 10 * scale; ctx.save(); // Main bag ctx.fillStyle = '#8B4513'; ctx.beginPath(); ctx.roundRect(bx - 10 * scale, by - 15 * scale, 20 * scale, 25 * scale, 3 * scale); ctx.fill(); // Straps ctx.strokeStyle = '#654321'; ctx.lineWidth = 2 * scale; ctx.beginPath(); ctx.moveTo(bx - 8 * scale, by - 15 * scale); ctx.lineTo(bx - 5 * scale, by - 25 * scale); ctx.moveTo(bx + 8 * scale, by - 15 * scale); ctx.lineTo(bx + 5 * scale, by - 25 * scale); ctx.stroke(); // Pocket ctx.fillStyle = '#A0522D'; ctx.beginPath(); ctx.roundRect(bx - 6 * scale, by - 5 * scale, 12 * scale, 10 * scale, 2 * scale); ctx.fill(); // Flap ctx.fillStyle = '#654321'; ctx.beginPath(); ctx.roundRect(bx - 8 * scale, by - 15 * scale, 16 * scale, 6 * scale, [3 * scale, 3 * scale, 0, 0]); ctx.fill(); ctx.restore(); } /** * Render utility belt * @param {CanvasRenderingContext2D} ctx * @param {number} x - Avatar center X * @param {number} y - Avatar waist Y * @param {number} scale - Avatar scale */ function renderUtilityBelt(ctx, x, y, scale) { ctx.save(); // Belt ctx.fillStyle = '#4A4A4A'; ctx.fillRect(x - 25 * scale, y, 50 * scale, 6 * scale); // Buckle ctx.fillStyle = '#C0C0C0'; ctx.fillRect(x - 5 * scale, y - 1 * scale, 10 * scale, 8 * scale); // Pouches ctx.fillStyle = '#5A5A5A'; ctx.fillRect(x - 22 * scale, y + 4 * scale, 8 * scale, 10 * scale); ctx.fillRect(x + 14 * scale, y + 4 * scale, 8 * scale, 10 * scale); ctx.restore(); } /** * Render sweatband * @param {CanvasRenderingContext2D} ctx * @param {number} x - Head center X * @param {number} y - Forehead Y * @param {number} scale - Avatar scale * @param {string} color - Band color */ function renderSweatband(ctx, x, y, scale, color = '#FF4444') { ctx.save(); ctx.fillStyle = color; ctx.beginPath(); ctx.ellipse(x, y, 22 * scale, 4 * scale, 0, 0, Math.PI * 2); ctx.fill(); // Stripe ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.beginPath(); ctx.ellipse(x, y - 1 * scale, 20 * scale, 1.5 * scale, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } /** * Render jersey number * @param {CanvasRenderingContext2D} ctx * @param {number} x - Back center X * @param {number} y - Back center Y * @param {number} scale - Avatar scale * @param {number} number - Jersey number * @param {boolean} facingRight - Avatar facing direction */ function renderJerseyNumber(ctx, x, y, scale, number, facingRight) { // Only show when avatar is facing away (back visible) if (facingRight) return; ctx.save(); ctx.font = `bold ${16 * scale}px Arial`; ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(number), x, y); ctx.restore(); } /** * Render wind-swept hair effect * @param {CanvasRenderingContext2D} ctx * @param {number} x - Head X * @param {number} y - Head top Y * @param {number} scale - Avatar scale * @param {number} intensity - Wind intensity * @param {number} time - Animation time */ function renderWindEffect(ctx, x, y, scale, intensity, time) { ctx.save(); const lineCount = 5; ctx.strokeStyle = 'rgba(200, 220, 255, 0.4)'; ctx.lineWidth = 2 * scale; ctx.lineCap = 'round'; for (let i = 0; i < lineCount; i++) { const startY = y + (i - lineCount / 2) * 8 * scale; const waveOffset = Math.sin(time * 8 + i) * 3 * scale; const length = (20 + Math.random() * 15) * scale * intensity; ctx.beginPath(); ctx.moveTo(x + 15 * scale, startY + waveOffset); ctx.quadraticCurveTo( x + 15 * scale + length / 2, startY + waveOffset + 5 * scale, x + 15 * scale + length, startY + waveOffset ); ctx.stroke(); } ctx.restore(); } // ============================================ // PURE CONTEXTS // ============================================ const platformerStandard = { id: 'platformer-standard', name: 'Platformer Hero', category: 'pure', avatarMode: 'FULL_BODY', animations: ['idle', 'walk', 'run', 'jump', 'fall', 'crouch', 'hurt', 'celebrate'], defaultAnimation: 'idle', accessoryVisibility: { eyewear: true, headwear: true, effects: true }, layerOrder: ['background-effects', 'avatar', 'foreground-effects'], recommendedFor: ['platformer', 'adventure', 'action'], description: 'Standard platforming character with no costume modifications', avatarPosition: { x: 0.5, y: 0.5 }, avatarScale: 1.0, renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) { const groundY = y + (avatar.height || 100) * scale / 2; const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0; // Shadow renderShadow(ctx, x, groundY, 50 * scale, 15 * scale, elevation); // Launch dust when jumping starts if (state.justJumped && state.jumpTime !== undefined) { renderDust(ctx, x, groundY, scale, 1.0, state.jumpTime, { particleCount: 8, spread: 30 * scale, color: 'rgba(139, 119, 101, 0.7)' }); } // Landing impact dust if (state.justLanded && state.landTime !== undefined) { renderDust(ctx, x, groundY, scale, 1.2, state.landTime, { particleCount: 10, spread: 40 * scale, color: 'rgba(139, 119, 101, 0.8)' }); } }, renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) { const groundY = y + (avatar.height || 100) * scale / 2; const headY = y - (avatar.height || 100) * scale / 2 + 15 * scale; // Running dust if (state.running && !state.jumping && !state.falling) { const dustTime = (time * 10) % 0.5; if (dustTime < 0.3) { renderDust(ctx, x + (state.facingRight ? -15 : 15) * scale, groundY, scale, 0.5, dustTime, { particleCount: 3, spread: 15 * scale }); } } // Hurt stars if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.5) { renderStars(ctx, x, headY, scale, 4, time); } // Celebration confetti if (state.celebrating && state.celebrateTime !== undefined) { renderConfetti(ctx, x, y - 50 * scale, scale, state.celebrateTime); } }, stateEffects: { running: { type: 'dust', frequency: 0.1 }, jumping: { type: 'launch-dust', once: true }, landing: { type: 'impact-dust', once: true }, hurt: { type: 'stars', duration: 0.5 }, celebrating: { type: 'confetti', duration: 3.0 } } }; const sportsPlayer = { id: 'sports-player', name: 'Sports Player', category: 'pure', avatarMode: 'FULL_BODY', animations: ['idle', 'walk', 'run', 'jump', 'fall', 'crouch', 'hurt', 'celebrate', 'throw', 'catch', 'kick'], defaultAnimation: 'idle', accessoryVisibility: { eyewear: true, headwear: true, effects: true }, layerOrder: ['background-effects', 'costume-back', 'avatar', 'costume-front', 'foreground-effects'], recommendedFor: ['sports', 'basketball', 'soccer', 'tennis', 'racing'], description: 'Athletic character with minimal sports equipment', avatarPosition: { x: 0.5, y: 0.5 }, avatarScale: 1.0, jerseyNumber: 7, renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) { const groundY = y + (avatar.height || 100) * scale / 2; const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0; // Court/field shadow (slightly elongated) renderShadow(ctx, x, groundY, 60 * scale, 12 * scale, elevation, { color: 'rgba(0, 0, 0, 0.25)' }); // Speed lines when running fast if (state.running && state.speed > 0.7) { const direction = state.facingRight ? 1 : -1; renderSpeedLines(ctx, x, y, scale, direction, state.speed * 0.8, { lineCount: 6, maxLength: 50, color: 'rgba(255, 255, 255, 0.5)' }); } // Athletic motion blur when jumping if (state.jumping && state.speed > 0.5) { const direction = state.facingRight ? 1 : -1; renderMotionBlur(ctx, x, y, scale, direction, state.speed * 0.6); } }, renderCostumeBehind: function(ctx, avatar, x, y, scale, transforms, time, state) { // Jersey number on back const backY = y - 10 * scale; renderJerseyNumber(ctx, x, backY, scale, this.jerseyNumber, state.facingRight); }, renderCostumeFront: function(ctx, avatar, x, y, scale, transforms, time, state) { // Sweatband on forehead const headY = y - (avatar.height || 100) * scale / 2 + 20 * scale; renderSweatband(ctx, x, headY, scale, '#FF4444'); }, renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) { const headY = y - (avatar.height || 100) * scale / 2 + 15 * scale; // Scoring celebration sparkles if (state.scoring && state.scoreTime !== undefined) { renderSparkle(ctx, x, y - 30 * scale, scale, state.scoreTime, { count: 8, spread: 60, color: '#FFD700', maxSize: 10 }); } // Hurt impact flash if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.3) { renderFlash(ctx, x, y, 80 * scale, 120 * scale, state.hurtTime, { color: 'rgba(255, 100, 100, 0.5)', duration: 0.3 }); } // Celebration if (state.celebrating && state.celebrateTime !== undefined) { renderSparkle(ctx, x, y, scale * 1.5, state.celebrateTime, { count: 12, spread: 80, color: '#FFFFFF' }); } }, stateEffects: { running: { type: 'speed-lines', threshold: 0.7 }, jumping: { type: 'motion-blur', once: false }, scoring: { type: 'sparkles', duration: 1.0 }, hurt: { type: 'flash', duration: 0.3 }, celebrating: { type: 'sparkles', duration: 2.0 } } }; const adventureHero = { id: 'adventure-hero', name: 'Adventure Hero', category: 'pure', avatarMode: 'FULL_BODY', animations: ['idle', 'walk', 'run', 'jump', 'fall', 'crouch', 'hurt', 'celebrate', 'climb', 'push', 'pull'], defaultAnimation: 'idle', accessoryVisibility: { eyewear: true, headwear: true, effects: true }, layerOrder: ['background-effects', 'costume-back', 'avatar', 'costume-front', 'foreground-effects'], recommendedFor: ['adventure', 'puzzle', 'exploration', 'rpg-lite'], description: 'Adventurous character with backpack and utility belt', avatarPosition: { x: 0.5, y: 0.5 }, avatarScale: 1.0, lightSource: { x: -0.5, y: -1 }, // Top-left light renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) { const groundY = y + (avatar.height || 100) * scale / 2; const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0; // Dynamic shadow based on light source const shadowOffsetX = -this.lightSource.x * 20 * scale; const shadowOffsetY = -this.lightSource.y * 5 * scale; renderShadow( ctx, x + shadowOffsetX * (1 - elevation / 100), groundY + shadowOffsetY, 55 * scale, 15 * scale, elevation ); // Subtle heroic glow when idle if (state.idle || (!state.running && !state.jumping && !state.falling)) { renderGlow(ctx, x, y, 100 * scale, 140 * scale, time, { color: 'rgba(255, 215, 0, 0.08)', pulseSpeed: 1.5, pulseAmount: 0.15 }); } // Determination lines when running if (state.running) { const direction = state.facingRight ? 1 : -1; renderSpeedLines(ctx, x, y - 20 * scale, scale, direction, 0.4, { lineCount: 4, maxLength: 30, color: 'rgba(255, 200, 100, 0.4)', spread: 50 }); } }, renderCostumeBehind: function(ctx, avatar, x, y, scale, transforms, time, state) { // Small backpack renderBackpack(ctx, x, y, scale, state.facingRight !== false); }, renderCostumeFront: function(ctx, avatar, x, y, scale, transforms, time, state) { // Thin utility belt const waistY = y + 15 * scale; renderUtilityBelt(ctx, x, waistY, scale * 0.8); }, renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) { const headY = y - (avatar.height || 100) * scale / 2 + 15 * scale; // Cape-like motion effect when jumping (no actual cape) if (state.jumping || state.falling) { const direction = state.facingRight ? -1 : 1; ctx.save(); ctx.strokeStyle = 'rgba(100, 150, 255, 0.3)'; ctx.lineWidth = 3 * scale; ctx.lineCap = 'round'; for (let i = 0; i < 3; i++) { const startX = x + direction * 20 * scale; const startY = y - 20 * scale + i * 15 * scale; const waveTime = time * 5 + i * 0.5; ctx.beginPath(); ctx.moveTo(startX, startY); ctx.quadraticCurveTo( startX + direction * 20 * scale, startY + Math.sin(waveTime) * 10 * scale, startX + direction * 35 * scale, startY + 10 * scale ); ctx.stroke(); } ctx.restore(); } // Discovery sparkle/shine if (state.discovering && state.discoverTime !== undefined) { renderSparkle(ctx, x + 40 * scale, y - 20 * scale, scale * 1.2, state.discoverTime, { count: 6, spread: 30, color: '#FFFF00', maxSize: 8, twinkleSpeed: 6 }); } // Damage flash when hurt if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.4) { renderFlash(ctx, x, y, 90 * scale, 130 * scale, state.hurtTime, { color: 'rgba(255, 50, 50, 0.4)', duration: 0.4, pulseCount: 3 }); } }, stateEffects: { idle: { type: 'glow', continuous: true }, running: { type: 'determination-lines', continuous: true }, jumping: { type: 'cape-motion', continuous: true }, discovering: { type: 'sparkle', duration: 1.5 }, hurt: { type: 'damage-flash', duration: 0.4 } } }; const runner = { id: 'runner', name: 'Speed Runner', category: 'pure', avatarMode: 'FULL_BODY', animations: ['idle', 'run', 'jump', 'slide', 'fall', 'hurt', 'celebrate'], defaultAnimation: 'run', accessoryVisibility: { eyewear: true, headwear: true, effects: true }, layerOrder: ['background-effects', 'avatar', 'foreground-effects'], recommendedFor: ['runner', 'endless-runner', 'racing', 'speed'], description: 'Streamlined character optimized for endless runner games', avatarPosition: { x: 0.4, y: 0.5 }, // Slightly left to show more forward space avatarScale: 1.0, renderEffectsBehind: function(ctx, avatar, x, y, scale, transforms, time, state) { const groundY = y + (avatar.height || 100) * scale / 2; const speed = state.speed || 0.5; const elevation = state.jumping || state.falling ? (state.elevation || 30) : 0; // Motion-blurred shadow ctx.save(); const shadowCount = Math.ceil(speed * 3); for (let i = shadowCount; i >= 0; i--) { const offsetX = i * 8 * scale; const alpha = 0.2 * (1 - i / (shadowCount + 1)) * (1 - elevation / 100); ctx.beginPath(); ctx.ellipse(x - offsetX, groundY, (50 - i * 5) * scale / 2, (12 - i * 2) * scale / 2, 0, 0, Math.PI * 2); ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`; ctx.fill(); } ctx.restore(); // Constant speed lines (intensity based on speed) renderSpeedLines(ctx, x, y, scale, 1, speed, { lineCount: Math.ceil(6 + speed * 6), maxLength: 40 + speed * 40, color: 'rgba(255, 255, 255, 0.6)', spread: 90 }); // Intensified speed effect when accelerating if (state.accelerating) { renderSpeedLines(ctx, x - 20 * scale, y, scale, 1, speed * 1.5, { lineCount: 10, maxLength: 80, color: 'rgba(255, 200, 100, 0.5)', spread: 100 }); } // Air trail when jumping if (state.jumping || state.falling) { const trailCount = 5; ctx.save(); for (let i = 1; i <= trailCount; i++) { const trailX = x - i * 15 * scale; const trailY = y + (state.falling ? -1 : 1) * i * 8 * scale; const alpha = 0.3 * (1 - i / (trailCount + 1)); ctx.beginPath(); ctx.ellipse(trailX, trailY, 15 * scale, 25 * scale, 0, 0, Math.PI * 2); ctx.fillStyle = `rgba(200, 220, 255, ${alpha})`; ctx.fill(); } ctx.restore(); } }, renderEffectsOver: function(ctx, avatar, x, y, scale, transforms, time, state) { const headY = y - (avatar.height || 100) * scale / 2; const groundY = y + (avatar.height || 100) * scale / 2; // Wind-swept hair effect const windIntensity = (state.speed || 0.5) + (state.accelerating ? 0.3 : 0); renderWindEffect(ctx, x, headY + 10 * scale, scale, windIntensity, time); // Ground friction sparks when sliding if (state.sliding && state.slideTime !== undefined) { renderSparks(ctx, x, groundY, scale, 1, state.slideTime, { count: 10, color: '#FFA500', highlightColor: '#FFFF00' }); } // Pickup sparkle when collecting if (state.collecting && state.collectTime !== undefined && state.collectTime < 0.5) { renderSparkle(ctx, x + 30 * scale, y - 20 * scale, scale, state.collectTime, { count: 8, spread: 25, color: '#00FF00', maxSize: 10, twinkleSpeed: 8 }); } // Stumble effect when hurt if (state.hurt && state.hurtTime !== undefined && state.hurtTime < 0.6) { // Shake effect const shakeIntensity = Math.max(0, 1 - state.hurtTime / 0.6); const shakeX = Math.sin(state.hurtTime * 50) * 5 * scale * shakeIntensity; const shakeY = Math.sin(state.hurtTime * 40) * 3 * scale * shakeIntensity; // Motion lines indicating stumble ctx.save(); ctx.strokeStyle = `rgba(255, 100, 100, ${shakeIntensity * 0.5})`; ctx.lineWidth = 2 * scale; ctx.setLineDash([5 * scale, 5 * scale]); for (let i = 0; i < 4; i++) { const angle = (i / 4) * Math.PI * 2 + state.hurtTime * 10; const radius = 40 * scale * shakeIntensity; ctx.beginPath(); ctx.moveTo(x + shakeX, y + shakeY); ctx.lineTo( x + shakeX + Math.cos(angle) * radius, y + shakeY + Math.sin(angle) * radius ); ctx.stroke(); } ctx.restore(); // Stars for big hits if (state.bigHit) { renderStars(ctx, x, headY + 10 * scale, scale, 3, time); } } }, stateEffects: { running: { type: 'speed-lines', continuous: true }, accelerating: { type: 'intensified-speed', continuous: true }, jumping: { type: 'air-trail', continuous: true }, sliding: { type: 'sparks', continuous: true }, collecting: { type: 'sparkle', duration: 0.5 }, hurt: { type: 'stumble', duration: 0.6 } } }; // ============================================ // PUBLIC API // ============================================ const CONTEXTS = [ platformerStandard, sportsPlayer, adventureHero, runner ]; window.PureContexts = { CONTEXTS: CONTEXTS, /** * Get a pure context by ID * @param {string} id - Context ID * @returns {Object|null} Context object or null if not found */ getById: function(id) { return CONTEXTS.find(function(ctx) { return ctx.id === id; }) || null; }, /** * Get all pure contexts * @returns {Array} Array of all pure contexts */ getAll: function() { return CONTEXTS.slice(); }, /** * Get contexts recommended for a specific game type * @param {string} gameType - Game type to match * @returns {Array} Array of matching contexts */ getRecommendedFor: function(gameType) { var type = gameType.toLowerCase(); return CONTEXTS.filter(function(ctx) { return ctx.recommendedFor.some(function(rec) { return rec.toLowerCase() === type || type.indexOf(rec.toLowerCase()) !== -1; }); }); }, // Shared effect renderers for reuse by other modules effects: { dust: renderDust, speedLines: renderSpeedLines, stars: renderStars, confetti: renderConfetti, shadow: renderShadow, sparkle: renderSparkle, flash: renderFlash, glow: renderGlow, motionBlur: renderMotionBlur, sparks: renderSparks, windEffect: renderWindEffect }, // Shared costume renderers for reuse costumes: { backpack: renderBackpack, utilityBelt: renderUtilityBelt, sweatband: renderSweatband, jerseyNumber: renderJerseyNumber } }; })();