1105 lines
34 KiB
JavaScript
1105 lines
34 KiB
JavaScript
/**
|
|
* 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
|
|
}
|
|
};
|
|
|
|
})();
|