Ένα MVC Χριστουγεννιάτικο Δέντρο σε C++ και ncurses
Παίζοντας τελευταία με τα templates της C++, έφτιαξα μια κλάση Oscillator (ταλαντωτή), η οποία εκτελεί μια εργασία με συγκεκριμένη συχνότητα. Στην πραγματικότητα είναι μια οικογένεια κλάσεων, μια που η συχνότητα είναι παραμετρική. Επιπλέον, μπορώ να δώσω μια καθυστέρηση (σε milliseconds) στον constructor, σε περίπτωση που θέλω να αναβάλω την πρώτη εκτέλεση:
template<typename Hz>
class Oscillator {
private:
thread taskLoop;
atomic_bool active=true;
using invertedHz=ratio<Hz::den,Hz::num>; // 1/Hz
using period_t=chrono::duration<int64_t,invertedHz>;
static constexpr period_t period{1};
protected:
virtual void task()=0;
public:
Oscillator(chrono::milliseconds delay=0ms) {
taskLoop=thread([this,delay](){
this_thread::sleep_for(delay);
while(active) {
auto t=chrono::steady_clock::now();
task();
this_thread::sleep_until(t+period);
}
});
}
~Oscillator() {
active=false;
taskLoop.join();
}
};
Σκεφτόμουν κάποιον τρόπο να τη δοκιμάσω και, μια που τα Χριστούγεννα είναι πολύ κοντά, αποφάσισα να φτιάξω ένα Χριστουγεννιάτικο δέντρο με φωτάκια που αναβοσβήνουν και αστέρια που λαμπυρίζουν. Θα είναι μια εφαρμογή MVC με ncurses, παρόμοια με αυτήν εδώ. Η view θα χρησιμοποιεί τον ταλαντωτή για να ενημερώνει την εμφάνισή της στα 50Hz, και το μοντέλο θα παρέχει τις τρέχουσες φωτεινότητες των αστεριών και των φώτων.
Αλλά πριν απ’ όλα χρειάστηκα ένα Χριστουγεννιάτικο δέντρο σε ascii art. Βρήκα ένα εδώ και το κατέβασα σε ένα αρχείο με όνομα xmas_tree:
* ,
_/^\_
< >
* /.-.\ *
* `/&\` *
,@.*;@,
/_o.I %_\ *
* (`'--:o(_@;
/`;--.,__ `') *
;@`o % O,*`'`&\
* (`'--)_@ ;o %'()\ *
/`;--._`''--._O'@;
/&*,()~o`;-.,_ `""`)
* /`,@ ;+& () o*`;-';\
(`""--.,_0 +% @' &()\
/-.,_ ``''--....-'`) *
* /@%;o`:;'--,.__ __.'\
;*,&(); @ % &^;~`"`o;@(); *
/(); o^~; & ().o@*&`;&%O\
jgs `"="==""==,,,.,="=="==="`
__.----.(\-''#####---...___...-----._
'` \)_`"""""`
.--' ')
o( )_-\
`"""` `Η View
Πριν ασχοληθούμε με τη συγκεκριμένη view μας, θέλω να φτιάξω ένα πιο γενικό template το οποίο θα απεικονίζει μια view οποιουδήποτε τύπου με δεδομένη συχνότητα:
template <class V,typename Hz>
class ViewRefreshRate: public V, public Oscillator<Hz> {
private:
mutex renderMutex;
protected:
virtual bool getRenderData()=0;
void task() override { render(); }
public:
void hide() override {
lock_guard<mutex> lock(renderMutex);
V::hide();
}
void render() override {
lock_guard<mutex> lock(renderMutex);
if(!V::showing()) return;
if(getRenderData()) V::render();
}
using CtxT=typename V::ctx_t;
ViewRefreshRate(CtxT *c,chrono::milliseconds d=0ms):
V(c), Oscillator<Hz>(d) { }
};
Η δουλειά του ταλαντωτή είναι απλά να απεικονίζει τη view. Εφόσον η render() μπορεί πλέον να κληθεί από διαφορετικά threads, πρέπει να την προστατέψουμε με έναν mutex. Αυτός ο mutex αποκλείει τις επικαλυπτόμενες κλήσεις της μεθόδου, όπως επίσης και την απόπειρα να κρύψουμε τη view ενόσω απεικονίζεται.
Η συγκεκριμένη view μας τώρα, θα είναι μια NCursesScreen με συχνότητα ανανέωσης 50Hz. Χρειάζεται να γνωρίζει τις τοποθεσίες των αστεριών και των λαμπών, όπως και τις εντάσεις και τα χρώματά τους. Όλα τα αστέρια θα είναι λευκά, ενώ θα χρωματίσουμε τις λάμπες με έξι διαφορετικά χρώματα. Οπότε μάζεψα όλες τις συντεταγμένες σε δύο vectors, μαζί με μηδενικές αρχικές εντάσεις και ένα χρώμα για κάθε λάμπα:
class JollyView: public ViewRefreshRate<NCursesScreen,ratio<50>> {
private:
string treeLines;
struct Pos {int y,x,intenScale;};
vector<Pos> stars={
{1,17,0},{4,5,0},{4,37,0},{5,14,0},
{5,47,0},{7,34,0},{8,8,0},{9,45,0},
{11,12,0},{11,40,0},{14,5,0},{16,40,0},
{17,10,0},{18,48,0},
};
struct LightBulbPos: public Pos {int color;};
vector<LightBulbPos> lights={
{7,23,0,COLOR_RED},{8,26,0,COLOR_GREEN},
{10,21,0,COLOR_BLUE},{11,27,0,COLOR_YELLOW},
{13,23,0,COLOR_MAGENTA},{14,28,0,COLOR_CYAN},
{17,19,0,COLOR_RED},{18,33,0,COLOR_GREEN},
{19,19,0,COLOR_CYAN},{19,29,0,COLOR_YELLOW},
};
void renderLight(int,int,char,int,int);
protected:
void produceContent() override;
public:
JollyView(NCursesCtx*,string);
};
void JollyView::renderLight(int y,int x,char c,int color,int intensity) {
int intensAttr[]={A_INVIS,A_DIM,A_NORMAL,A_BOLD};
attrset(intensAttr[intensity]|COLOR_PAIR(color));
mvprintw(y,x,"%c",c);
}
void JollyView::produceContent() {
attrset(A_NORMAL);
printw("%s",treeLines.c_str());
for(auto &i:stars) renderLight(i.y,i.x,'*',COLOR_WHITE,i.intenScale);
for(auto &i:lights) renderLight(i.y,i.x,'o',i.color,i.intenScale);
}
JollyView::JollyView(NCursesCtx *c,string lines): ViewRefreshRate(c), treeLines(lines) {
srand(time({}));
random_shuffle(lights.begin(),lights.end());
}
Για να απεικονίσουμε τη view, πρέπει πρώτα να σχεδιάσουμε την εικόνα του δέντρου και μετά να τοποθετήσουμε τα αστέρια και τις λάμπες με τις τρέχουσες εντάσεις τους. Χρησιμοποιούμε τις ιδιότητες invisible, dim, normal και bold από το ncurses, συνεπώς το πεδίο της έντασης στους vectors θα πρέπει να είναι ακέραιος από 0 έως 3.
Το πεδίο treeLines παρέχεται ως παράμετρος στον constructor, ο οποίος επιπλέον ανακατεύει τον vector με τις λάμπες. Αυτό το τελευταίο χρειάζεται διότι θέλω οι μισές λάμπες να αναβοσβήνουν γρήγορα και οι υπόλοιπες αργά. Αλλά δεν θέλω το ένα μισό να μαζεύεται στην κορυφή του δέντρου και το άλλο στη βάση. Αυτό θα γίνει πιο σαφές αργότερα, όταν μιλήσουμε για το μοντέλο.
Να σημειώσουμε εδώ ότι χρησιμοποιώ την COLOR_PAIR() με παράμετρο το χρώμα. Αυτό υπονοεί μια αντιστοίχιση όπως η παρακάτω, την οποία θα υλοποιήσουμε στον controller:
COLOR_XXX --> (COLOR_XXX,COLOR_BLACK)
Το τελευταίο που έχουμε να καλύψουμε είναι η υλοποίηση της getRenderData(). Αυτό είναι το σημείο στο οποίο η view μας θα επικοινωνεί με το μοντέλο. Η μέθοδος θα πρέπει να ανακτά έναν vector με τις εντάσεις που έχουν μεταβληθεί έπειτα από κάποιο χρονικό σημείο:
struct LightIntensity {
int index;
double intensity;
};
typedef vector<LightIntensity> LightIntensities;
using time_point_t=chrono::time_point<chrono::steady_clock>;
typedef function<LightIntensities*(time_point_t)> updateEvent;
class JollyView: public ViewRefreshRate<NCursesScreen,ratio<50>> {
private:
/* ... */
time_point_t lastChecked=time_point_t::min();
updateEvent onGetIntensities;
protected:
/* ... */
bool getRenderData() override;
public:
/* ... */
void setOnGetIntensities(updateEvent);
};
void JollyView::setOnGetIntensities(updateEvent f) { onGetIntensities=f; }
bool JollyView::getRenderData() {
auto *changes=onGetIntensities(lastChecked);
if(!changes->size()) return false;
lastChecked=chrono::steady_clock::now();
for(auto &i:*changes) {
auto intenScale=lround(3*i.intensity);
auto idx=i.index;
if(idx<stars.size()) stars[idx].intenScale=intenScale;
else lights[idx-stars.size()].intenScale=intenScale;
}
return true;
}
Ο controller θα χρησιμοποιήσει τελικά την setOnGetIntensities() για να συνδέσει το πεδίο onGetIntensities με την πραγματική ρουτίνα χειρισμού του μοντέλου, η οποία με τη σειρά της θα παράγει έναν vector με δείκτες από 0 έως 23 και εντάσεις από 0 έως 1. Οι πρώτοι 14 δείκτες θα αντιστοιχούν σε αστέρια και οι υπόλοιποι σε λάμπες. Η δουλειά της getRederData() θα είναι να μετατρέψει αυτές τις εντάσεις σε ακέραιες από 0 έως 3 και να ενημερώσει τους εσωτερικούς vectors αναλόγως.
Το Μοντέλο
Το μοντέλο καθεαυτό είναι σχετικά απλό. Ο constructor θα δημιουργεί τα αστέρια και τις λάμπες, ο destructor θα τα διαγράφει και θα υπάρχει μια μέθοδος getIntensities() για να μπορούν οι views να ζητούν τις πρόσφατες αλλαγές στις εντάσεις:
class JollyModel {
private:
vector<TwinkleStar*> stars;
vector<SlowLightBulb*> slowLights;
vector<FastLightBulb*> fastLights;
LightIntensities changedIntensities;
public:
JollyModel();
~JollyModel();
LightIntensities *getIntensities(time_point_t);
};
JollyModel::JollyModel() {
srand(time({}));
for(int i=0;i<14;i++)
stars.push_back(new TwinkleStar(1s*rand()%12));
for(int i=0;i<5;i++)
fastLights.push_back(new FastLightBulb(1ms*rand()%5000));
for(int i=0;i<5;i++)
slowLights.push_back(new SlowLightBulb(1s*rand()%8));
}
JollyModel::~JollyModel() {
for(auto &i:stars) delete i;
for(auto &i:fastLights) delete i;
for(auto &i:slowLights) delete i;
}
LightIntensities *JollyModel::getIntensities(time_point_t lastChecked) {
changedIntensities.clear();
int i=0;
for(auto &l:stars) {
if(l->getLastChanged()>lastChecked) {
auto li=LightIntensity{i,l->getIntensity()};
changedIntensities.push_back(li);
}
i++;
}
for(auto &l:fastLights) {
if(l->getLastChanged()>lastChecked) {
auto li=LightIntensity{i,l->getIntensity()};
changedIntensities.push_back(li);
}
i++;
}
for(auto &l:slowLights) {
if(l->getLastChanged()>lastChecked) {
auto li=LightIntensity{i,l->getIntensity()};
changedIntensities.push_back(li);
}
i++;
}
return &changedIntensities;
}
Κάθε αστέρι και λάμπα έχει μια τυχαία αρχική καθυστέρηση, ενώ το πεδίο changedIntensities χρησιμοποιείται σαν προσωρινή αποθήκευση που κρατάει τις αλλαγές των εντάσεων που πρέπει να δοθούν στις views που τις ζητούν.
Τα δύσκολα αρχίζουν με τα αστέρια και τις λάμπες καθεαυτά. Ο Oscillator μας δεν αρκεί σε αυτή την περίπτωση. Χρειαζόμαστε έναν νέο που θα εκτελεί μια εργασία (να αλλάζει την ένταση) σε κάθε κλάσμα της πραγματικής συχνότητας. Θα ονομάσουμε αυτή την κλάση FracOscillator:
template<typename Hz,typename Frac>
class FracOscillator: public Oscillator<ratio_divide<Hz,Frac>> {
using Oscillator<ratio_divide<Hz,Frac>>::Oscillator;
private:
int step=0;
protected:
double percent;
virtual void fracTask()=0;
void task() override {
step=(step+Frac::num)%Frac::den;
percent=(double)step/Frac::den;
fracTask();
}
};
Με δεδομένο ότι η παράμετρος Frac θα είναι τυπικά μια αναλογία μικρότερη του 1, η εσωτερική συχνότητα ουσιαστικά πολλαπλασιάζεται. Το πεδίο percent κρατά το τρέχον ποσοστό της πραγματικής περιόδου, και πλεον έχουμε να υλοποιήσουμε την fracTask(). Για τα αστέρια και τα λαμπιόνια μας, θα το κάναμε κάπως έτσι:
template<typename Hz,typename Frac>
class FlickeringLight: public FracOscillator<Hz,Frac> {
using FracOscillator<Hz,Frac>::FracOscillator;
private:
double intensity;
time_point_t lastChanged;
protected:
void fracTask() override {
auto perc2Pi=2.0*numbers::pi*this->percent;
intensity=(1-cos(perc2Pi))/2;
lastChanged=chrono::steady_clock::now();
}
public:
double getIntensity() { return intensity; }
time_point_t getLastChanged() { return lastChanged; }
};
class TwinkleStar: public FlickeringLight<ratio<1,12>,ratio<1,100>> {
using FlickeringLight::FlickeringLight;
};
class FastLightBulb: public FlickeringLight<ratio<1>,ratio<1,100>> {
using FlickeringLight::FlickeringLight;
};
class SlowLightBulb: public FlickeringLight<ratio<1,5>,ratio<1,100>> {
using FlickeringLight::FlickeringLight;
};
Η ένταση ακολουθεί μια συνημιτονοειδή καμπύλη και οι δύο μέθοδοι επιστρέφουν την ένταση και το χρονικό σημείο που αυτή άλλαξε, αντίστοιχα. Όλα υπολογίζουν την έντασή τους 100 φορές μέσα σε μια περίοδο, αλλά η συχνότητα των αστεριών είναι 1/12Hz, των γρήγορων λαμπών 1Hz και των αργών 1/5Hz.
Ο Controller
Ο controller λειτουργεί ως το σημείο εισόδου της εφαρμογής εμφανίζοντας τη view:
class JollyController {
private:
NCursesCtx ctx;
JollyView *view;
JollyModel *model;
public:
JollyController();
~JollyController();
void showView();
};
void JollyController::showView() { view->show(); }
Στον constructor διαβάζουμε το αρχείο με την εικόνα και το τροφοδοτούμε στη view, και μετά συνδέουμε χειριστές συμβάντων στα ανάλογα events. Μετά από αυτό, ρυθμίζουμε τα ζευγάρια των χρωμάτων σύμφωνα με την αντιστοιχία που αναφέραμε στο κομμάτι της view παραπάνω:
ollyController::JollyController() {
model=new JollyModel();
ifstream file("xmas_tree");
string treeLines,str;
while (getline(file,str)) treeLines+=str+"\n";
view=new JollyView(treeLines);
view->setOnExit([this](){ view->hide(); });
view->setOnUnhandledKey([](int){ beep(); });
view->setOnGetIntensities(bind(&JollyModel::getIntensities,model,_1));
start_color();
auto colors={COLOR_WHITE,COLOR_RED,COLOR_GREEN,COLOR_BLUE,COLOR_YELLOW,COLOR_CYAN,COLOR_MAGENTA,};
for(auto &i:colors) init_pair(i,i,COLOR_BLACK);
}
Ο destructor διαγράφει το μοντέλο και τη view:
JollyController::~JollyController() {
delete view;
delete model;
}
Και τέλος, η main() απλά καλεί τη μέθοδο showView() του controller:
int main() { JollyController().showView(); }
Το αποτέλεσμα είναι κάτι τέτοιο:
Καλά Χριστούγεννα!

