Files
Learn_System/frontend/js/pet-sprite.js
T
Maxim Dolgolyov 667054fa58 feat(dashboard): hero-карточки главной — чтение, лаборатория дня, питомец
Пересборка верхней зоны дашборда по скриншоту (редизайн был утерян):
- 3 hero-карточки вместо action-cards: «Начать чтение» (продолжение
  курса через /api/courses/continue), «Лаборатория дня» (детерминир.
  выбор по дню + SVG-превью из lab-previews.js), «Питомец» (синхрон
  с модулем /pet через /api/pet + единый PetSprite.render).
- Подключены восстановленные ассеты pet-sprite.js и lab-previews.js.
- Убран weak-topics из hero; питомец показывает уровень/XP/стрик/
  цель дня/настроение, синхронно со страницей /pet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:11:20 +03:00

319 lines
15 KiB
JavaScript

'use strict';
/*
* PetSprite — единый источник модели питомца для /pet и /dashboard.
* Канонические PET_PALETTES + shadeColor + renderPetSVG. pet.html и
* dashboard.html используют window.PetSprite.render(...) — без дублей.
*/
(function () {
const PET_PALETTES = {
purple:'#9B5DE5', cyan:'#06D6E0', gold:'#F9C74F',
red:'#F94144', green:'#38D95A', blue:'#4A90D9',
};
function shadeColor(hex, pct) {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255,Math.max(0,(n>>16)+pct));
const g = Math.min(255,Math.max(0,((n>>8)&0xff)+pct));
const b = Math.min(255,Math.max(0,(n&0xff)+pct));
return `rgb(${r},${g},${b})`;
}
function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak = 0) {
const col = PET_PALETTES[colorKey] || '#9B5DE5';
const dark = shadeColor(col, -45);
const light = shadeColor(col, 52);
const uid = `pg${level}${mood[0]}${colorKey[0]}`;
const bodyPath = 'M55,22 C70,22 86,37 87,56 C89,75 78,94 55,97 C32,94 21,75 23,56 C24,37 40,22 55,22 Z';
const eyeY = 52, eyeX1 = 40, eyeX2 = 70;
/* ── Eyebrows ── */
let eyebrows = '';
if (mood !== 'sleeping') {
if (mood === 'ecstatic') {
eyebrows = `<path d="M33,46 C37,42 43,42 47,46" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<path d="M63,46 C67,42 73,42 77,46" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"/>`;
} else if (mood === 'sad' || mood === 'hungry') {
eyebrows = `<path d="M33,47 C37,51 43,51 47,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M63,47 C67,51 73,51 77,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>`;
} else {
eyebrows = `<path d="M33,47 Q40,45 47,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M63,47 Q70,45 77,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>`;
}
}
/* ── Eyes ── */
let eyeGroups = '', cheeks = '', extras = '';
if (mood === 'sleeping') {
eyeGroups = `<path d="M${eyeX1-8},${eyeY} Q${eyeX1},${eyeY-8} ${eyeX1+8},${eyeY}" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M${eyeX2-8},${eyeY} Q${eyeX2},${eyeY-8} ${eyeX2+8},${eyeY}" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
} else {
const ry1 = mood === 'ecstatic' ? 13 : mood === 'happy' ? 12 : 10.5;
const prx = mood === 'ecstatic' ? 7 : mood === 'happy' ? 6.5 : 5.5;
const pry = mood === 'ecstatic' ? 8 : mood === 'happy' ? 7.5 : 6.5;
const pOff = (mood === 'sad' || mood === 'hungry') ? 3 : 2;
eyeGroups = `
<g class="pet-eye-blink" style="transform-box:fill-box;transform-origin:center">
<ellipse cx="${eyeX1}" cy="${eyeY}" rx="10" ry="${ry1}" fill="white"/>
<ellipse class="pet-pupil" cx="${eyeX1+1}" cy="${eyeY+pOff}" rx="${prx}" ry="${pry}" fill="#111"/>
<circle cx="${eyeX1-3}" cy="${eyeY-3}" r="2" fill="white" opacity=".9"/>
</g>
<g class="pet-eye-blink2" style="transform-box:fill-box;transform-origin:center">
<ellipse cx="${eyeX2}" cy="${eyeY}" rx="10" ry="${ry1}" fill="white"/>
<ellipse class="pet-pupil" cx="${eyeX2+1}" cy="${eyeY+pOff}" rx="${prx}" ry="${pry}" fill="#111"/>
<circle cx="${eyeX2-3}" cy="${eyeY-3}" r="2" fill="white" opacity=".9"/>
</g>`;
}
/* ── Nose ── */
const nose = mood !== 'sleeping'
? `<ellipse cx="55" cy="62" rx="3.5" ry="2.5" fill="${dark}" opacity=".45"/>` : '';
/* ── Mouth + cheeks ── */
let mouth = '';
if (mood === 'sleeping') {
mouth = `<path d="M47,68 Q55,72 63,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
} else if (mood === 'ecstatic') {
mouth = `<path d="M43,67 Q55,82 67,67" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
cheeks = `<ellipse cx="24" cy="62" rx="9" ry="6" fill="${col}" opacity=".4"/>
<ellipse cx="86" cy="62" rx="9" ry="6" fill="${col}" opacity=".4"/>`;
} else if (mood === 'happy') {
mouth = `<path d="M44,68 Q55,80 66,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
cheeks = `<ellipse cx="25" cy="62" rx="8" ry="5.5" fill="${col}" opacity=".32"/>
<ellipse cx="85" cy="62" rx="8" ry="5.5" fill="${col}" opacity=".32"/>`;
} else if (mood === 'sad' || mood === 'hungry') {
mouth = `<path d="M44,72 Q55,64 66,72" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
if (mood === 'hungry')
extras = `<ellipse cx="78" cy="59" rx="2.5" ry="3.5" fill="rgba(130,195,255,.8)"/>`;
} else {
mouth = `<path d="M44,68 Q55,74 66,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
}
/* ── Animated tail (all levels) ── */
const tail = `<g>
<path d="M68,91 C78,88 92,86 90,101 C88,110 76,110 72,103 C68,97 66,95 68,91 Z" fill="${col}" stroke="${dark}" stroke-width="1.2"/>
<animateTransform attributeName="transform" type="rotate" values="-7 68 91; 7 68 91; -7 68 91" dur="0.75s" repeatCount="indefinite"/>
</g>`;
/* ── Ears (level 2+) ── */
let ears = '';
if (level >= 2) {
ears = `<ellipse cx="32" cy="34" rx="9" ry="12" fill="${col}" transform="rotate(-15 32 34)"/>
<ellipse cx="32" cy="35" rx="6" ry="8" fill="${light}" opacity=".4" transform="rotate(-15 32 35)"/>
<ellipse cx="78" cy="34" rx="9" ry="12" fill="${col}" transform="rotate(15 78 34)"/>
<ellipse cx="78" cy="35" rx="6" ry="8" fill="${light}" opacity=".4" transform="rotate(15 78 35)"/>`;
}
/* ── Antennae (level 3+) ── */
let antennae = '';
if (level >= 3) {
antennae = `<line x1="44" y1="27" x2="34" y2="11" stroke="${col}" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="33" cy="9" r="5" fill="${col}"/>
<circle cx="31" cy="7" r="2" fill="white" opacity=".7"/>
<line x1="66" y1="27" x2="76" y2="11" stroke="${col}" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="77" cy="9" r="5" fill="${col}"/>
<circle cx="75" cy="7" r="2" fill="white" opacity=".7"/>`;
}
/* ── Wings with flutter (level 4+) ── */
let wings = '';
if (level >= 4) {
wings = `<g>
<path d="M14,62 C2,46 2,30 16,38 C26,44 22,59 18,65 Z" fill="${light}" opacity=".65"/>
<path d="M16,62 C6,50 8,38 18,42 C26,46 24,57 20,62 Z" fill="white" opacity=".15"/>
<animateTransform attributeName="transform" type="rotate" values="0 14 62; -10 14 62; 0 14 62" dur="0.45s" repeatCount="indefinite"/>
</g>
<g>
<path d="M96,62 C108,46 108,30 94,38 C84,44 88,59 92,65 Z" fill="${light}" opacity=".65"/>
<path d="M94,62 C104,50 102,38 92,42 C84,46 86,57 90,62 Z" fill="white" opacity=".15"/>
<animateTransform attributeName="transform" type="rotate" values="0 96 62; 10 96 62; 0 96 62" dur="0.45s" repeatCount="indefinite"/>
</g>`;
}
/* ── Paws with fingers ── */
const paws = `
<ellipse cx="14" cy="76" rx="12" ry="8" fill="url(#${uid})" transform="rotate(-25 14 76)"/>
<circle cx="8" cy="82" r="2.8" fill="${dark}" opacity=".45"/>
<circle cx="13" cy="84" r="2.8" fill="${dark}" opacity=".45"/>
<circle cx="18" cy="83" r="2.8" fill="${dark}" opacity=".45"/>
<ellipse cx="96" cy="76" rx="12" ry="8" fill="url(#${uid})" transform="rotate(25 96 76)"/>
<circle cx="102" cy="82" r="2.8" fill="${dark}" opacity=".45"/>
<circle cx="97" cy="84" r="2.8" fill="${dark}" opacity=".45"/>
<circle cx="92" cy="83" r="2.8" fill="${dark}" opacity=".45"/>`;
/* ── Accessories ── */
let accSvg = '';
if (accessories.includes('hat')) {
accSvg += `<rect x="36" y="22" width="38" height="6" rx="3" fill="#2a2a2a"/>
<rect x="42" y="6" width="26" height="17" rx="4" fill="#1a1a1a"/>
<rect x="42" y="6" width="26" height="5" rx="2" fill="#333" opacity=".6"/>`;
}
if (accessories.includes('glasses')) {
accSvg += `<circle cx="${eyeX1}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
<circle cx="${eyeX2}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
<line x1="54" y1="${eyeY}" x2="56" y2="${eyeY}" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
<line x1="19" y1="${eyeY-3}" x2="26" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>
<line x1="91" y1="${eyeY-3}" x2="84" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>`;
}
if (accessories.includes('crown') || level >= 5) {
accSvg += `<path d="M33,26 L41,10 L55,22 L69,10 L77,26 L78,32 L32,32 Z" fill="#F9C74F"/>
<rect x="32" y="28" width="46" height="6" rx="2" fill="#F9C74F"/>
<circle cx="41" cy="11" r="4" fill="#F94144"/>
<circle cx="55" cy="23" r="4" fill="#06D6E0"/>
<circle cx="69" cy="11" r="4" fill="#9B5DE5"/>`;
}
if (accessories.includes('star') && level < 5) {
accSvg += `<polygon points="98,18 100,24 106,24 101,28 103,34 98,30 93,34 95,28 90,24 96,24" fill="#F9C74F"/>`;
}
/* ── Aura ring (level 4+) ── */
let aura = '';
if (level >= 4) {
aura = `<g>
<circle cx="55" cy="60" r="47" fill="none" stroke="${col}" stroke-width="2.5" stroke-dasharray="9 6">
<animate attributeName="opacity" values=".35;.55;.35" dur="2.5s" repeatCount="indefinite"/>
</circle>
<animateTransform attributeName="transform" type="rotate" from="0 55 60" to="360 55 60" dur="9s" repeatCount="indefinite"/>
</g>`;
}
/* ── B3 Rainbow collar (streak ≥ 7) ── */
let rainbowCollar = '';
if (streak >= 7) {
rainbowCollar = `<g style="animation:rbRot 3s linear infinite;transform-box:fill-box;transform-origin:55px 38px">
<defs>
<linearGradient id="${uid}rb" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#F94144"/>
<stop offset="20%" stop-color="#F9C74F"/>
<stop offset="40%" stop-color="#38D95A"/>
<stop offset="60%" stop-color="#06D6E0"/>
<stop offset="80%" stop-color="#9B5DE5"/>
<stop offset="100%" stop-color="#F94144"/>
</linearGradient>
</defs>
<ellipse cx="55" cy="38" rx="22" ry="8" fill="none" stroke="url(#${uid}rb)" stroke-width="3.5" opacity=".88"/>
</g>`;
}
/* ── Orbital particles (level 5+) ── */
let orbitals = '';
if (level >= 5) {
const oData = [
{ start: 0, dur: 2.8, r: 46, size: 4, fill: col, op: .85 },
{ start: 120, dur: 3.6, r: 43, size: 3, fill: light, op: .7 },
{ start: 240, dur: 2.2, r: 48, size: 3.5, fill: 'white', op: .6 },
];
// Extra orbitals for level 6+
if (level >= 6) {
oData.push(
{ start: 60, dur: 1.9, r: 51, size: 2.5, fill: light, op: .65 },
{ start: 180, dur: 4.1, r: 40, size: 2, fill: col, op: .55 },
{ start: 300, dur: 3.0, r: 54, size: 3, fill: 'white', op: .5 },
);
}
// Even more for level 8
if (level >= 8) {
oData.push(
{ start: 45, dur: 1.5, r: 56, size: 3.5, fill: '#FFD700', op: .9 },
{ start: 225, dur: 2.4, r: 38, size: 2.5, fill: '#FFD700', op: .75 },
);
}
orbitals = oData.map(o => `<g>
<circle cx="55" cy="${60 - o.r}" r="${o.size}" fill="${o.fill}" opacity="${o.op}"/>
<animateTransform attributeName="transform" type="rotate"
from="${o.start} 55 60" to="${o.start + 360} 55 60" dur="${o.dur}s" repeatCount="indefinite"/>
</g>`).join('\n');
}
/* ── Second aura ring (level 6+) ── */
if (level >= 6) {
aura += `<g>
<circle cx="55" cy="60" r="53" fill="none" stroke="${col}" stroke-width="1.5" stroke-dasharray="5 9" opacity="0.4">
<animate attributeName="opacity" values=".25;.45;.25" dur="3.2s" repeatCount="indefinite"/>
</circle>
<animateTransform attributeName="transform" type="rotate" from="360 55 60" to="0 55 60" dur="14s" repeatCount="indefinite"/>
</g>`;
}
/* ── Crystal halo (level 7+) ── */
let halo = '';
if (level >= 7) {
halo = `<g>
<ellipse cx="55" cy="18" rx="20" ry="5" fill="none" stroke="${col}" stroke-width="2.5" opacity=".75">
<animate attributeName="opacity" values=".55;.85;.55" dur="2s" repeatCount="indefinite"/>
</ellipse>
<ellipse cx="55" cy="18" rx="14" ry="3" fill="none" stroke="white" stroke-width="1" opacity=".4"/>
</g>`;
}
/* ── Second wing pair (level 7+) ── */
if (level >= 7) {
wings += `<g>
<path d="M18,55 C8,43 10,33 20,37 C27,40 25,50 22,55 Z" fill="${light}" opacity=".45"/>
<animateTransform attributeName="transform" type="rotate" values="0 18 55; -12 18 55; 0 18 55" dur="0.35s" repeatCount="indefinite"/>
</g>
<g>
<path d="M92,55 C102,43 100,33 90,37 C83,40 85,50 88,55 Z" fill="${light}" opacity=".45"/>
<animateTransform attributeName="transform" type="rotate" values="0 92 55; 12 92 55; 0 92 55" dur="0.35s" repeatCount="indefinite"/>
</g>`;
}
/* ── Third aura + body shimmer (level 8) ── */
let shimmer = '';
if (level >= 8) {
aura += `<g>
<circle cx="55" cy="60" r="58" fill="none" stroke="#FFD700" stroke-width="1" stroke-dasharray="3 12" opacity="0.35">
<animate attributeName="opacity" values=".2;.5;.2" dur="4s" repeatCount="indefinite"/>
</circle>
<animateTransform attributeName="transform" type="rotate" from="0 55 60" to="360 55 60" dur="20s" repeatCount="indefinite"/>
</g>`;
shimmer = `<ellipse cx="55" cy="58" rx="30" ry="35" fill="url(#${uid}sh)" opacity="0.18"/>`;
}
return `<svg viewBox="0 0 110 115" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="${uid}" cx="38%" cy="28%" r="70%">
<stop offset="0%" stop-color="${light}"/>
<stop offset="55%" stop-color="${col}"/>
<stop offset="100%" stop-color="${dark}"/>
</radialGradient>
<radialGradient id="${uid}b" cx="50%" cy="55%" r="50%">
<stop offset="0%" stop-color="white" stop-opacity="0.22"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="${uid}sh" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#FFD700" stop-opacity="1"/>
<stop offset="100%" stop-color="#FFD700" stop-opacity="0"/>
</radialGradient>
</defs>
<ellipse cx="55" cy="111" rx="28" ry="4" fill="rgba(0,0,0,.18)"/>
${aura}
${halo}
${tail}
${wings}
${ears}
${antennae}
${paws}
<path d="${bodyPath}" fill="url(#${uid})"/>
${shimmer}
<ellipse cx="55" cy="70" rx="18" ry="12" fill="url(#${uid}b)"/>
${cheeks}${eyebrows}${eyeGroups}${nose}${mouth}${extras}
${rainbowCollar}
${accSvg}
${orbitals}
</svg>`;
}
var MOOD_RU = {
ecstatic: 'в восторге', happy: 'в духе', neutral: 'бодр',
sad: 'скучает', hungry: 'голоден', sleeping: 'спит',
};
window.PetSprite = {
PALETTES: PET_PALETTES,
shade: shadeColor,
render: renderPetSVG,
moodLabel: function (m) { return MOOD_RU[m] || 'бодр'; },
};
})();