Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
import React from 'react';
|
||
import Icon from './Icon';
|
||
|
||
/**
|
||
* PlayerHud — HUD игрока в Play-режиме:
|
||
* - HP-полоска слева сверху с сегментами и подсветкой
|
||
* - Красный flash при получении урона
|
||
* - Счётчик патронов справа снизу с кольцом перезарядки
|
||
*/
|
||
function PlayerHud({ visible, hp, maxHp, ammo, damaged }) {
|
||
if (!visible) return null;
|
||
|
||
const hpPct = Math.max(0, Math.min(100, (hp / maxHp) * 100));
|
||
const lowHp = hp <= maxHp * 0.25;
|
||
const midHp = !lowHp && hp <= maxHp * 0.5;
|
||
|
||
const hpGradient = lowHp
|
||
? 'linear-gradient(90deg, #ff6f7a 0%, #c0303f 100%)'
|
||
: midHp
|
||
? 'linear-gradient(90deg, #ffc857 0%, #f59e0b 100%)'
|
||
: 'linear-gradient(90deg, #22d97a 0%, #0f9d56 100%)';
|
||
|
||
const hpGlow = lowHp
|
||
? '0 0 16px rgba(255, 111, 122, 0.55)'
|
||
: midHp
|
||
? '0 0 12px rgba(255, 200, 87, 0.35)'
|
||
: '0 0 12px rgba(34, 217, 122, 0.35)';
|
||
|
||
return (
|
||
<>
|
||
<style>{HUD_KEYFRAMES}</style>
|
||
|
||
{/* === HP-bar — ЛЕВЫЙ ВЕРХНИЙ УГОЛ === */}
|
||
<div style={{
|
||
position: 'absolute',
|
||
left: 14,
|
||
top: 14,
|
||
width: 260,
|
||
pointerEvents: 'none',
|
||
zIndex: 30,
|
||
background: 'rgba(20, 24, 45, 0.78)',
|
||
border: '1px solid rgba(255, 255, 255, 0.10)',
|
||
borderRadius: 14,
|
||
padding: '10px 12px',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
boxShadow:
|
||
'0 8px 24px rgba(0, 0, 0, 0.45), '
|
||
+ '0 0 0 1px rgba(51, 87, 255, 0.10), '
|
||
+ 'inset 0 1px 0 rgba(255, 255, 255, 0.06)',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
marginBottom: 6,
|
||
}}>
|
||
<div style={{
|
||
fontSize: 18, lineHeight: 1,
|
||
animation: lowHp ? 'hudHeartBeat 0.8s ease-in-out infinite' : 'none',
|
||
filter: lowHp ? 'drop-shadow(0 0 8px rgba(255,111,122,0.7))' : 'none',
|
||
}}><Icon name="heart" size={14} /></div>
|
||
<div style={{
|
||
fontSize: 14, fontWeight: 800, color: '#f1f5fb',
|
||
textShadow: '0 1px 3px rgba(0,0,0,0.7)',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
letterSpacing: 0.3,
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
{Math.round(hp)}<span style={{
|
||
color: 'rgba(241, 245, 251, 0.42)',
|
||
fontWeight: 700, fontSize: 12, marginLeft: 2,
|
||
}}> / {maxHp}</span>
|
||
</div>
|
||
<div style={{
|
||
marginLeft: 'auto',
|
||
fontSize: 10, fontWeight: 800,
|
||
color: lowHp ? '#ff6f7a' : midHp ? '#ffc857' : '#22d97a',
|
||
textTransform: 'uppercase', letterSpacing: 1,
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
}}>
|
||
{Math.round(hpPct)}%
|
||
</div>
|
||
</div>
|
||
|
||
{/* Полоска */}
|
||
<div style={{
|
||
height: 10,
|
||
background: 'rgba(0, 0, 0, 0.45)',
|
||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||
borderRadius: 999,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
boxShadow: 'inset 0 2px 4px rgba(0, 0, 0, 0.4)',
|
||
}}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: `${hpPct}%`,
|
||
background: hpGradient,
|
||
borderRadius: 999,
|
||
transition: 'width 200ms ease, background 200ms ease, box-shadow 200ms ease',
|
||
boxShadow: hpGlow,
|
||
position: 'relative',
|
||
}}>
|
||
{/* Блик сверху */}
|
||
<div style={{
|
||
position: 'absolute', left: 0, right: 0, top: 0, height: '50%',
|
||
background: 'linear-gradient(180deg, rgba(255,255,255,0.28), transparent)',
|
||
borderRadius: '999px 999px 0 0',
|
||
}} />
|
||
</div>
|
||
{/* Сегментные риски (10 шт) */}
|
||
{Array.from({ length: 9 }).map((_, i) => (
|
||
<div key={i} style={{
|
||
position: 'absolute',
|
||
top: 0, bottom: 0,
|
||
left: `${(i + 1) * 10}%`,
|
||
width: 1,
|
||
background: 'rgba(0, 0, 0, 0.35)',
|
||
pointerEvents: 'none',
|
||
}} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Красный flash при получении урона */}
|
||
{damaged && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
zIndex: 29,
|
||
background: 'radial-gradient(circle, transparent 25%, rgba(255, 60, 80, 0.55) 100%)',
|
||
animation: 'hudHurtFlash 0.4s ease-out',
|
||
}} />
|
||
)}
|
||
|
||
{/* === Ammo — справа внизу, над hot-bar === */}
|
||
{ammo && (
|
||
<AmmoIndicator ammo={ammo} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const AmmoIndicator = ({ ammo }) => {
|
||
const reloading = !!ammo.reloading;
|
||
const magPct = ammo.magazineMax > 0
|
||
? (ammo.magazine / ammo.magazineMax) * 100
|
||
: 0;
|
||
const lowAmmo = !reloading && magPct < 25;
|
||
|
||
// SVG-кольцо progress перезарядки
|
||
const RING_R = 30;
|
||
const RING_C = 2 * Math.PI * RING_R;
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'absolute',
|
||
right: 18,
|
||
bottom: 100,
|
||
pointerEvents: 'none',
|
||
zIndex: 30,
|
||
background: 'rgba(20, 24, 45, 0.82)',
|
||
border: '1px solid rgba(255, 255, 255, 0.10)',
|
||
borderRadius: 16,
|
||
padding: '14px 18px',
|
||
color: '#f1f5fb',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
minWidth: 150,
|
||
textAlign: 'center',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
boxShadow:
|
||
'0 12px 32px rgba(0, 0, 0, 0.50), '
|
||
+ '0 0 0 1px rgba(51, 87, 255, 0.12), '
|
||
+ 'inset 0 1px 0 rgba(255, 255, 255, 0.06)',
|
||
}}>
|
||
{reloading ? (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||
}}>
|
||
{/* Кольцо progress */}
|
||
<div style={{ position: 'relative', width: 72, height: 72 }}>
|
||
<svg width={72} height={72} style={{ transform: 'rotate(-90deg)' }}>
|
||
<circle
|
||
cx={36} cy={36} r={RING_R}
|
||
stroke="rgba(255,255,255,0.08)"
|
||
strokeWidth={5}
|
||
fill="none"
|
||
/>
|
||
<circle
|
||
cx={36} cy={36} r={RING_R}
|
||
stroke="#3357ff"
|
||
strokeWidth={5}
|
||
fill="none"
|
||
strokeLinecap="round"
|
||
strokeDasharray={RING_C}
|
||
strokeDashoffset={RING_C * (1 - ammo.reloadProgress)}
|
||
style={{
|
||
transition: 'stroke-dashoffset 0.05s linear',
|
||
filter: 'drop-shadow(0 0 6px rgba(51, 87, 255, 0.65))',
|
||
}}
|
||
/>
|
||
</svg>
|
||
<div style={{
|
||
position: 'absolute', inset: 0,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 22,
|
||
animation: 'hudSpin 1.2s linear infinite',
|
||
}}><Icon name="refresh" size={14} /></div>
|
||
</div>
|
||
<div style={{
|
||
fontSize: 11, fontWeight: 800, color: '#3357ff',
|
||
textTransform: 'uppercase', letterSpacing: 1.5,
|
||
}}>
|
||
Перезарядка
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Magazine */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'baseline',
|
||
justifyContent: 'center', gap: 4,
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
<span style={{
|
||
fontSize: 32, fontWeight: 900, lineHeight: 1,
|
||
color: lowAmmo ? '#ff6f7a' : '#f1f5fb',
|
||
letterSpacing: -1,
|
||
textShadow: lowAmmo
|
||
? '0 0 12px rgba(255, 111, 122, 0.55)'
|
||
: 'none',
|
||
animation: lowAmmo ? 'hudPulse 1s ease-in-out infinite' : 'none',
|
||
}}>
|
||
{ammo.magazine}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 14, fontWeight: 700,
|
||
color: 'rgba(241, 245, 251, 0.42)',
|
||
}}>
|
||
/ {ammo.magazineMax}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Тонкая полоска текущей обоймы */}
|
||
<div style={{
|
||
height: 3,
|
||
background: 'rgba(0, 0, 0, 0.4)',
|
||
borderRadius: 999,
|
||
overflow: 'hidden',
|
||
marginTop: 6, marginBottom: 8,
|
||
}}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: `${magPct}%`,
|
||
background: lowAmmo
|
||
? 'linear-gradient(90deg, #ff6f7a, #c0303f)'
|
||
: 'linear-gradient(90deg, #3357ff, #1e2da5)',
|
||
borderRadius: 999,
|
||
transition: 'width 200ms ease',
|
||
boxShadow: lowAmmo
|
||
? '0 0 8px rgba(255, 111, 122, 0.5)'
|
||
: '0 0 8px rgba(51, 87, 255, 0.4)',
|
||
}} />
|
||
</div>
|
||
|
||
{/* Reserve + R hint */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center',
|
||
justifyContent: 'space-between', gap: 8,
|
||
fontSize: 11,
|
||
}}>
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
color: 'rgba(241, 245, 251, 0.62)', fontWeight: 700,
|
||
}}>
|
||
<Icon name="backpack" size={14} /> {ammo.reserve}
|
||
</span>
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
fontSize: 10, color: 'rgba(241, 245, 251, 0.42)',
|
||
fontWeight: 700, letterSpacing: 0.5,
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
<kbd style={{
|
||
background: 'rgba(255, 255, 255, 0.08)',
|
||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||
borderRadius: 4,
|
||
padding: '1px 6px',
|
||
fontSize: 10, fontFamily: 'inherit', fontWeight: 800,
|
||
color: '#f1f5fb',
|
||
}}>R</kbd>
|
||
перезаряд
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const HUD_KEYFRAMES = `
|
||
@keyframes hudHurtFlash {
|
||
0% { opacity: 1; }
|
||
100% { opacity: 0; }
|
||
}
|
||
@keyframes hudHeartBeat {
|
||
0%, 100% { transform: scale(1); }
|
||
25%, 75% { transform: scale(1.18); }
|
||
50% { transform: scale(0.95); }
|
||
}
|
||
@keyframes hudSpin { to { transform: rotate(360deg); } }
|
||
@keyframes hudPulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.55; }
|
||
}
|
||
`;
|
||
|
||
export default PlayerHud;
|