SettingsThere comes a time in every engine's life when you, or the user, wants to customise something about it's operation. Perhaps you'd like to run it at a higher screen resolution, or turn off the sound. You have the luxury of the code, so you can go in and change what you want, and just recompile; but the user can't do that. It's time for a user-targeted settings mechanism. So how do we expose our settings in a flexible, reusable way? We want them all in a central place - so that a CSettingsManager can load into them with ease - but we don't want to hard-code them (especially given that your game will probably add it's own settings to the engine's list). We need to handle different types of setting - floats, ints, strings, and so on. We also need to handle 'single' settings versus 'multiple' settings - that is, settings which contain a list of values, such as the maps in the map rotator for a multiplayer server. We're going to use another utility object, a distant cousin of one introduced in the previous article: a Dator (loose relative of Functor). As you may have guessed from the name, a dator is an object which wraps a data member (as opposed to a Functor which wraps a function). Because we need to establish a single format for all data to be passed to the dator in (even if it then gets converted to another specific type), we're going to say that a dator accepts std::strings as input; it will then convert from that std::string to the format it's been templated to handle, using a trick borrowed from boost:lexical_cast, that you'll see in a minute. It may also be necessary to have a list of dators for some reason. Because Dator<int> and Dator<float> are considered distinctly seperate types, we can't put them in a list together; instead we have to establish a common base class for all dators and have a list of that. Here's the base class: class BaseDator : public IMMObject { protected: BaseDator(){} BaseDator(BaseDator &b){(*this)=b;} public: virtual BaseDator &operator =(std::string &s)=0; virtual BaseDator &operator +=(std::string &s)=0; virtual BaseDator &operator -=(std::string &s)=0; virtual bool operator ==(std::string &s)=0; virtual bool operator !=(std::string &s)=0; virtual bool hasMultipleValues()=0; virtual operator std::string()=0; }; You can see how all the operators take std::strings as parameters (and have return types, this time!), establishing it as the 'global type' for dators to transfer values in and out. There's also hasMultipleValues() - overridden by derived classes to indicate whether the dator works with a single value (like a normal variable) or a set of values (a std::list). Finally, there's an operator to convert the value back to a std::string for other uses. Pretty much all of these functions are pure virtual, and the constructors are protected: you're not going to be instantiating this directly, folks. :) template<class T> class Dator : public BaseDator { protected: T& target; T toVal(std::string &s) { std::stringstream str; str.unsetf(std::ios::skipws); str<<input; T res; str>>res; return res; } std::string toString(T &val) { std::stringstream str; str.unsetf(std::ios::skipws); str<<val; std::string res; str>>res; return res; } public: Dator(T& t) : target(t) {} BaseDator &operator =(std::string &input) { target=toVal(input); return *this; } BaseDator &operator +=(std::string &input) { target+=toVal(input); return *this; } BaseDator &operator -=(std::string &input) { target-=toVal(input); return *this; } bool operator ==(std::string &s) { return (s==(std::string)(*this)); } bool operator !=(std::string &s) { return (s!=(std::string)(*this)); } operator std::string() { return toString(target); } bool hasMultipleValues() { return false; } AUTO_SIZE; }; OK. That's the class for a dator which represents a single value (hasMultipleValues returns false). Look at the constructor and protected data member - we're working with references, which means that when you create a dator, you'll bind it to an existing variable - any assignments to the dator will result in assignments to the variable itself. That's why we'll hardly ever need to work with the dator when we know what type it is - we'll work with the variable it's bound to instead. What's the std::stringstream stuff about, I hear you ask? That's the trick I mentioned, borrowed from boost::lexical_cast. You see, we can't just assign a std::string to an arbitrary type - there's no built-in conversion available. In C, we had to use the atoi()/atof()/atol() family of functions; we could, in theory, do that here, but that requires template specialisation, which is not much good (at least under MSVC) - it practically forces us to copy+paste code for each specialised type, which is exactly what the templates were there to avoid. So, what we do is take advantage of the fact that std::stringstream has overloaded input/output operators for most standard types; we write the string out to it using operator <<(std::string), and then read it back in again using operator >>(T &). The type-conversion is performed for us by the stringstream class. You can see how it works in the toVal() and toString() functions - it's reversible, too. If you try and set up a variable for a type that stringstream can't convert to, then you'll get a compile error, but it's possible to provide global overloads (for example, operator<<(std::string) isn't actually a member of the class, it's globally defined in the 'string' header). template<class T> class ListDator : public BaseDator { protected: std::list<T> &values; T toVal(std::string &s) { std::stringstream str; str.unsetf(std::ios::skipws); str<<input; T res; str>>res; return res; } std::string toString(T &val) { std::stringstream str; str.unsetf(std::ios::skipws); str<<val; std::string res; str>>res; return res; } public: ListDator(std::list<T> &v) : values(v) { } BaseDator &operator =(std::string &s) { values.clear(); values.push_back(toVal(s)); return *this; } BaseDator &operator +=(std::string &s) { values.push_back(toVal(s)); return *this; } BaseDator &operator -=(std::string &s) { values.remove(toVal(s)); return *this; } bool operator ==(std::string &s) { return (std::find(values.begin(),values.end(),toVal(s))!=values.end()); } bool operator !=(std::string &s) { return !((*this)==s); } operator std::string() { return toString(values.back()); } operator std::list<T>&() { return values; } bool hasMultipleValues(){return true;} AUTO_SIZE; }; And that's the class for a dator which can hold multiple values (hasMultipleValues returns true). It's pretty similar to the previous class - ideally, we'd use the previous class for this - but std::list doesn't provide +=, -=, or = operators so we can't (and my attempt to provide global overloads failed). Operator = will clear the list and add a single value; operators += and -= will add and remove values respectively. Operator == returns true if the list *contains* the given value - it could only be one of many though. The std::string type converter returns the last value added to the list (values.back()), and a function is there to get a reference to the list itself (though, like normal dators, you'll usually have direct access to the list without needing the dator). How do you use these classes? //we can have a variable of pretty much any type - let's pick int for simplicity int someValue; //we can then create a dator bound to it like this CMMPointer< Dator<int> > dator=new Dator<int>(someValue); //if we then assign to the dator... (*dator)=std::string("5"); //the value of someValue should now be 5. //using ListDators is pretty similar, as I said earlier std::list<int> someValues; CMMPointer< ListDator<int> > listDator=new ListDator(someValues); (*listDator)=std::string("5"); (*listDator)+=std::string("6"); (*listDator)-=std::string("5"); //someValues should now have the single entry 6. Dators will be useful later on for scripting, but right now, we're going to use them for our settings mechanism. As you might expect, we need a Singleton-based Manager to handle all the settings for us. class CSettingsManager : public Singleton<CSettingsManager> { public: CSettingsManager(); virtual ~CSettingsManager(); void RegisterVariable(std::string &name, CMMPointer<BaseDator> &var); void SetVariable(std::string &name, std::string &value, int bias=0); void CreateStandardSettings(); void DestroyStandardSettings(); void ParseSetting(std::string str); void ParseFile(std::string filename); protected: std::map<std::string, CMMPointer<BaseDator> > settingMap; }; We've got the familiar Singleton syntax, a constructor and a destructor. RegisterVariable should be obvious - and that's where the dators come in. SetVariable has one parameter, 'bias,' that you might be wondering about, but we'll get to that in a minute. ParseSetting takes a single name=value expression, splits it up and passes it onto SetVariable; and ParseFile opens a given file and hands each line to ParseSetting (allowing you to build up 'settings files'). The only totally unexpected functions there are CreateStandardSettings and DestroyStandardSettings. Now I don't like the StandardSettings mechanism. It feels messy, and like there should be a better way to do it. But as far as I can tell, there isn't. CreateStandardSettings is the function which creates all of the settings used by the engine itself - the renderer's screenX, screenY, and screenBPP, for example. I feel that creation of those should fall to the renderer (obviously), but we get a chicken-and-the-egg situation; we can't create the renderer until we've loaded in it's settings, but we can't load in it's settings till they've been registered. The one possible ray of hope is to use static object variables for the dators; but they couldn't be registered in the CSettingsManager because it needs to be created first, with a 'new CSettingsManager()' line. As Ned Flanders would say, it's quite a dilly of a pickle. So the best we can do is provide this function, CreateStandardSettings, to be called when the CSettingsManager is created. Because it's called by the manager itself, there's no risk that the manager doesn't exist when it gets called. Also, we don't have to desert static variables completely - we can put static pointers and values into the renderer class, and have CSettingsManager store the dators there when they get created. We also, therefore, need to pair up CreateStandardSettings() with DestroyStandardSettings(), because those static pointers (which are smart pointers, of course) don't try and delete the dators they point to until the program is unloaded from memory (which is *after* the call to CollectRemainingObjects). DestroyStandardSettings will set all those static pointers to zero, so they don't try and call Release() on already-deleted objects. We're about to look at some function code, but first I want to explain the 'bias' parameter passed to SetVariable. 'Bias,' in this situation, basically means 'Add/Remove.' When we're talking about list settings, we can't assign to them in the normal way; and flags need some kind of boolean expression for whether they should be set or unset. So, when dealing with list or flag settings, the parser requests that the name of the setting be prefixed with '+' or '-'; '+' for positive, 'enable' bias, and '-' for negative, 'disable' bias. They can still be specified for non-list, non-flag variables - they'll just be ignored - and if left off, a positive bias is assumed. Here's the simple functions, from both CSettingsManager, and the couple of undefined CSetting functions: CSettingsManager::CSettingsManager() { settingMap.clear(); CreateStandardSettings(); } CSettingsManager::~CSettingsManager() { DestroyStandardSettings(); } void CSettingsManager::RegisterVariable(std::string &name, CMMPointer<BaseDator> &var) { settingMap[name]=var; } void CSettingsManager::ParseFile(std::string filename) { std::ifstream in(filename.c_str()); if(!in.is_open())return; //couldn't open while(!in.eof()) { char szBuf[1024]; in.getline(szBuf,1024); ParseSetting(szBuf); } } //set up a couple of macros for the StandardSettings mechanism - just convenience jobs //each macro takes the type of dator, the CMMPointer<> to store it in, the variable //it's bound to, and the name the manager should use to refer to it. #define SETTING(type, target, var, name) target=new Dator<type>(var); \ RegisterVariable(std::string(name),CMMPointer<BaseDator>(target)); #define LIST(type, target, var, name) target=new ListDator<type>(var); \ RegisterVariable(std::string(name),CMMPointer<BaseDator>(target)); void CSettingsManager::CreateStandardSettings() { //empty for the time being } void CSettingsManager::DestroyStandardSettings() { //also empty } OK, those are simple enough. The CSettingsManager constructor/destructor spends most of it's time setting up the StandardSettings; and RegisterVariable() just adds the pointer to the map. Let's move onto the two more complex functions, SetVariable and ParseSetting: void CSettingsManager::SetVariable(std::string &name, std::string &value, int bias) { if(!settingMap[name])return; //setting doesn't exist if(settingMap[name]->hasMultipleValues()) { std::list<std::string> valueList; valueList.clear(); //check for semicolon-seperated values if(value.find(';')!=-1) { //split the string into semicolor-seperated chunks int first=0, last; while((last=value.find(';',first))!=-1) { valueList.push_back(value.substr(first,last-first)); first=last+1; } valueList.push_back(value.substr(first)); }else{ valueList.push_back(value); } for(std::list<std::string>::iterator it=valueList.begin(); it!=valueList.end(); it++) { if(bias>0) { (*settingMap[name])+=(*it); }else if(bias<0) { (*settingMap[name])-=(*it); }else{ (*settingMap[name])=(*it); } } }else{ //just assign the value (*settingMap[name])=value; } } void CSettingsManager::ParseSetting(std::string str) { int bias=0; std::string name, value; //test for bias if((str[0]=='+')||(str[0]=='-')) { bias=((str[0]=='+')*2)-1; //+ maps to 1*2-1=1, - maps to 0*2-1=-1 str=str.substr(1); //remove the first character from the string } //test for '=' int eqPos=str.find('='); if(eqPos!=-1) { //there's an = sign in there //so split either side of it name=str.substr(0,eqPos); value=str.substr(eqPos+1); }else{ //there's no equal sign //we use the bias to construct a boolean value //so that flags can be +flag (mapping to flag=1) or -flag (mapping to flag=0) name=str; char szBuf[5]; sprintf(szBuf,"%i",(bias+1)/2); value=szBuf; } //set the variable SetVariable(name,value,bias); } Still fairly simple, just a little larger. The SetVariable function splits the value string down into its component parts (if they exist). Then, using the bias value, it adds or removes each part from the list. If, on the other hand, it's a simple non-list variable, it just sets the value. The SetVariable function is called by the ParseSetting function, which simply extracts the bias and splits the string using the '=' sign in the middle (if, again, it exists). So what syntax should we use for our options, in the end? The syntax rules look something like this: Setting: [biasValue]valueName[=valueList] biasValue: +, - valueList: value[;valueList] Where [] indicates 'optional,' and a comma indicates 'or.' If you want to test it, try creating a Dator of some basic type such as 'int', registering it with the CSettingsManager, passing a value to it through the command-line (MSVC users: Project->Settings->Debug->Program Arguments), and then logging that value back out. Play around; after all, this is a game engine! ;-)
|
|