In Pharaoh il cammino dalle semplici capanne alle ville sontuose, decorate con affreschi e colonne, non è solo una questione di architettura e di texture diverse. È il riflesso di quanto il giocatore si prende cura della sua città virtuale, dei suoi bisogni, della sua fede e della sua sicurezza. Ogni abitazione della città è una FSM che reagisce alle condizioni circostanti: abbondanza di cibo, accesso all'acqua, riti religiosi, svaghi culturali e molto altro.
Non appena inizierete a soddisfare bisogni sempre più vari dei cittadini, le loro case cominceranno a cambiare: a volte impercettibilmente, a volte in un lampo. Questo sistema è il cuore stesso del gioco, come lo era già nel capitolo precedente della serie: le case si influenzano a vicenda e influenzano gli edifici vicini, e dietro una componente visiva semplice, un paio di texture per livello di casa, si nasconde un complesso meccanismo di simulazione che rende unico ogni quartiere della città.
In questo articolo provo a raccontare come funziona l'evoluzione delle case, quali requisiti stanno dietro a ogni livello abitativo e come tutto questo era implementato nel gioco originale. Se per caso vi siete persi l'ultima riunione della nostra cerchia sacerdotale... pardon, gli articoli precedenti sul recupero dei sorgenti di questo vecchio city builder, trovate assolutamente il tempo di dare un'occhiata ad alcuni passaggi interessanti (Benvenuti nell'Antico…, ecs, dynvtbl, thread logici e Pharaoh, Come costruire una mastaba, Come viene disegnata la mappa in Pharaoh, Una nuova casa per il Faraone)
Tutti gli screenshot dell'articolo sono stati fatti con il renderer del progetto, i sorgenti sono su github
Teoria senza codice
I vostri sudditi pretendono sempre qualcosa: grano, birra, un posto dove pregare... Naturalmente i bisogni sono una parte inseparabile del gioco e del ciclo di vita della città: è un city builder, dopotutto. I bisogni si trasformano in vantaggi concreti: gli abitanti soddisfatti pagano più tasse, si ribellano più di rado e non hanno fretta di appiccare il fuoco alle case dei vicini (la meccanica dei rioters). In queste condizioni la città prospera, e le statue in vostro onore si innalzano verso il dio del sole Ra.
Ogni bisogno soddisfatto deve innescare una certa logica di gioco, che si tratti di maggiore stabilità, di tasse o di stanze per nuovi abitanti. Non so come si chiamassero queste meccaniche nel gioco originale; dalla mia corrispondenza con Simon Bradbury, tech lead dell'originale Caesar3, ho capito che lui le chiama proprietà o attributi (attributes). Queste proprietà si combinano in un sistema armonioso, che richiede pensiero strategico e pianificazione su scala dell'intera città, e gli errori vengono puniti piuttosto in fretta con un esodo della popolazione, da qualche parte verso le azzime e la terra promessa.
Al giocatore di Caesar/Pharaoh/Zeus questi attributi erano nascosti dietro testi del tipo "Serve un tempio, manca l'acqua pulita, oppure il granaio dà fastidio": era un tratto distintivo della serie e permetteva di realizzare una meccanica in apparenza complessa (niente numeri espliciti), con interazioni tra edifici e desiderabilità del terreno. Ma dentro, questo sistema si è rivelato sorprendentemente semplice.
Cose come il reddito, la felicità (desirability, la desiderabilità del terreno) e la popolazione erano già presenti nei capitoli precedenti della serie. Come al solito, scegliere quali bisogni soddisfare per primi e quali obiettivi aggiuntivi completare per ottenere bonus fa parte del rompicapo dello sviluppo della città. Proprio un rompicapo: i livelli originali erano calcolati in modo che il giocatore trovasse una soluzione efficiente con un insieme limitato di risorse; i livelli vanilla, sia di Caesar sia di Pharaoh, erano stati creati proprio come un puzzle di risorse con un numero limitato di pezzi.
I parametri principali delle case sono la popolazione, le risorse consumate e i servizi forniti dai vari edifici, e crescono soprattutto soddisfacendo i bisogni delle case, collocando nelle vicinanze gli edifici che li forniscono. La meccanica dei parametri è stata ereditata direttamente da Caesar: se garantite cibo e accesso all'acqua pulita agli abitanti delle case dei primi livelli, ognuna di queste abitazioni servite dà già un bonus di popolazione e la possibilità di ricavare un reddito, se costruite un esattore delle tasse.
Tra l'altro il gioco mostra solo le quantità di merci immagazzinate nella casa in quel momento e la descrizione di ciò che manca all'edificio per salire di livello.
Ancora un po' di teoria
Ma non si tratta solo di felicità, reddito e popolazione. Nel gruppo degli attributi rientrano anche la sicurezza antincendio, la sanità e una serie di altri parametri legati alle possibilità che si sbloccano man mano che si avanza nelle missioni. Anche questi vengono misurati con dei numeri, ma il giocatore non li vede affatto: nello screenshot qui sotto si vede come il livello di rischio d'incendio di un edificio viene rappresentato tramite overlay, e come esiste realmente nel gioco.
Nel gioco originale l'elenco dei layer di overlay non era molto lungo, ma sono riuscito a recuperarne di più: a quanto pare gli autori non li ritenevano importanti, oppure non hanno fatto in tempo a rifinirli per l'uscita.
Un ampio insieme di attributi legati al sistema dei bisogni offre grande libertà sia nella progettazione dei legami stessi, sia in termini di rigiocabilità relativamente infinita e di un gran numero di combinazioni di attributi, lasciando al giocatore la scelta dei modi di risolvere i problemi più adatti al suo stile di gestione o alla situazione attuale sulla mappa.
Overlay
enum e_overlay {
OVERLAY_NONE = 0,
OVERLAY_WATER = 2,
OVERLAY_RELIGION = 4,
OVERLAY_FIRE = 8,
OVERLAY_DAMAGE = 9,
OVERLAY_CRIME = 10,
OVERLAY_ENTERTAINMENT = 11,
OVERLAY_BOOTH = 12,
OVERLAY_BANDSTAND = 13,
OVERLAY_PAVILION = 14,
OVERLAY_SENET_HOUSE = 15,
OVERLAY_EDUCATION = 16,
OVERLAY_SCRIBAL_SCHOOL = 17,
OVERLAY_LIBRARY = 18,
OVERLAY_ACADEMY = 19,
OVERLAY_APOTHECARY = 20,
OVERLAY_DENTIST = 21,
OVERLAY_PHYSICIAN = 22,
OVERLAY_MORTUARY = 23,
OVERLAY_TAX_INCOME = 24,
OVERLAY_FOOD_STOCKS = 25,
OVERLAY_DESIRABILITY = 26,
OVERLAY_WORKERS_UNUSED = 27,
OVERLAY_NATIVE = 28,
OVERLAY_PROBLEMS = 29,
/// qui finivano gli overlay visibili dell'originale
/// ma ce n'erano anche altri, e non li ho ancora estratti tutti
OVERLAY_RELIGION_OSIRIS = 30,
OVERLAY_RELIGION_RA = 31,
OVERLAY_RELIGION_PTAH = 32,
OVERLAY_RELIGION_SETH = 33,
OVERLAY_RELIGION_BAST = 34,
OVERLAY_FERTILITY = 35,
OVERLAY_BAZAAR_ACCESS = 36,
OVERLAY_ROUTING = 37,
OVERLAY_HEALTH = 38,
OVERLAY_LABOR = 39,
OVERLAY_COUTHOUSE = 40,
OVERLAY_BREWERY = 41,
OVERLAY_LABOR_ACCESS = 42,
OVERLAY_SIZE
};I parametri sono davvero tanti, e per permettere al giocatore di avere un quadro più completo dello stato della mappa si usavano i cosiddetti overlay, che visualizzano sulla mappa i diversi parametri, come nei due screenshot qui sopra. Questo aiuta a capire rapidamente cosa influisce su un certo indicatore e, ad esempio, a rimediare a un basso livello di sicurezza antincendio ricostruendo un quartiere o aggiungendo nuovi edifici. Il disegno degli overlay è un argomento a parte, anch'esso interessante, ma tocca molto il rendering ed esce dai confini di questo articolo; se a qualcuno interessa, ne scriverò separatamente.
Risolvere il puzzle dei bisogni ha un ruolo chiave nella crescita della popolazione e nel completamento del livello: tutti gli attributi forniti da un edificio residenziale dipendono direttamente da quali bisogni vi sono soddisfatti - questa formula era già stata collaudata in Caesar e si era dimostrata valida. I bisogni di livello più alto danno valori degli attributi più alti, sia direttamente (grazie alla crescita del livello della casa), sia indirettamente: i nuovi bisogni richiedono nuovi edifici, che di solito richiedono nuove risorse, per le quali servono altri edifici che le producano. Ecco le catene produttive nascoste.
Il codice
La FSM dell'evoluzione delle case si trova (per ora) nel file building_house.cpp (https://github.com/dalerank/Akhenaten/blob/master/src/building/building_house.cpp)
Il lavoro della FSM è diviso in più parti: consumo di cibo, di risorse, di servizi e infine il vero e proprio miglioramento o declassamento della casa. Dato che nel motore non ci sono praticamente allocazioni di memoria, tutto questo gira piuttosto veloce su mappe grandi, in un solo thread. Per una parte dei dati non sono mai riuscito a capire a cosa servissero, quindi sono marcati più o meno così: unused.unknown_00c0
void city_resources_t::consume_food(const simulation_time_t& t) {
calculate_available_food();
g_city.unused.unknown_00c0 = 0;
resource_list consumed_food;
buildings_house_do([&] (building_house *house) {
resource_list consumed = house->consume_food();
consumed_food.append(consumed);
});
res_this_month.consumed.append(consumed_food);
}
void city_resources_t::consume_goods(const simulation_time_t& t) {
if (t.day == 0 || t.day == 7) {
resource_list consumed_goods;
buildings_house_do([&] (building_house *house) {
auto house_consumed = house->consume_resources();
consumed_goods.append(house_consumed);
});
res_this_month.consumed.append(consumed_goods);
}
}
All'inizio, nei sorgenti sviscerati c'era un'enorme salsiccia di if, e anche la versione ripulita superava le ottocento righe. Che fosse il compilatore ad aver srotolato così i sorgenti originali (cosa dubbia, visto che il motore è stato scritto nel '98 e i compilatori ottimizzanti non erano ancora molto diffusi) o che fosse proprio l'idea originale, quel che era, era.
Archeologia
if (state == HouseState::CRUDE_HUT) {
if (status == e_house_evolve)
change_to(BUILDING_HOUSE_STURDY_HUT);
} else if (state == HouseState::STURDY_HUT) {
if (status == e_house_evolve)
change_to(BUILDING_HOUSE_MEAGER_SHANTY);
else if (status == e_house_decay)
change_to(BUILDING_HOUSE_CRUDE_HUT);
} else if (state == HouseState::MEAGER_SHANTY) {
if (status == e_house_evolve)
change_to(BUILDING_HOUSE_COMMON_SHANTY);
else if (status == e_house_decay)
change_to(BUILDING_HOUSE_STURDY_HUT);
} else if (state == HouseState::COMMON_SHANTY) {
....Ho dovuto ripulire a lungo questa logica per ottenerne una versione leggibile, e alla fine tutto si è ridotto a uno switch più piccolo, di questo tipo:
Versione leggibile
enum HouseState {
CRUDE_HUT,
STURDY_HUT,
MEAGER_SHANTY,
COMMON_SHANTY,
ROUGH_COTTAGE,
// ...
PALATIAL_ESTATE
};
struct House {
HouseState state;
house_demands* demands;
bool evolve() {
if (house_population() <= 0)
return false;
merge(); // Avviene sempre se pop > 0
auto status = check_requirements(demands);
if (has_devolve_delay(status))
return false;
switch (state) {
case HouseState::CRUDE_HUT:
if (status == e_house_evolve)
change_to(BUILDING_HOUSE_STURDY_HUT);
break;
case HouseState::STURDY_HUT:
if (status == e_house_evolve)
change_to(BUILDING_HOUSE_MEAGER_SHANTY);
else if (status == e_house_decay)
change_to(BUILDING_HOUSE_CRUDE_HUT);
break;
// ... e così via per ogni stato ...
case HouseState::FANCY_RESIDENCE:
if (status == e_house_evolve && can_expand(9)) {
expand_to_common_manor();
map_tiles_gardens_update_all();
return true;
} else if (status == e_house_decay)
change_to(BUILDING_HOUSE_ELEGANT_RESIDENCE);
break;
// ...
default:
break;
}
return false;
}
};Più tardi ancora tutta la logica è stata divisa in implementazioni separate, in cui ha iniziato a intravedersi una parvenza di struttura che ormai si riesce a modificare senza lacrime. C'è ancora parecchio copia-incolla, ma non ho avuto tempo di sistemarlo ulteriormente.
bool building_house_elegant_manor::evolve(house_demands* demands) {
if (house_population() <= 0) {
return false;
}
e_house_progress status = check_requirements(demands);
if (has_devolve_delay(status)) {
return false;
}
if (status == e_house_evolve) {
change_to(base, BUILDING_HOUSE_STATELY_MANOR);
} else if (status == e_house_decay) {
change_to(base, BUILDING_HOUSE_SPACIOUS_MANOR);
}
return false;
}Per passare al livello successivo, una casa deve verificare la disponibilità di varie condizioni: più alto è il livello, più servizi le servono. Questi controlli sono piuttosto semplici, quindi si possono eseguire spesso senza temere per gli fps; gli autori del gioco originale (tenete presente che il gioco doveva girare su mostri da 200 megahertz; l'autore dell'articolo ha giocato per la prima volta all'OG su un AMD K6 a 266 MHz (Super Socket 7, se non ricordo male) e doveva abbassare la velocità della città perché tutto andava troppo veloce) facevano questo controllo una volta per giorno di gioco, per tutte le case della mappa insieme, il che dava un aggiornamento delle case un po' robotico nelle prime fasi del gioco, quando la città è piccola.
+--------------------------+ +-----------------------------+
| Start & house_level | +----> | Religion checks: |
| if for_upgrade: ++level | | | - num_gods vs requirement |
+--------------------------+ | | - update demands->missing |
| | | - update demands->requiring |
v | +-----------------------------+
+--------------------------+ | |
| Load model for level | | v
| model = model_get_house | | +-----------------------------+
+--------------------------+ | | Health services: |
| | | - dentist, magistrate |
v | | - health level check |
+--------------------------+ | | - update demands->missing |
| Water access: | | | - update demands->requiring |
| - needs fountain or well | | +-----------------------------+
| - update demands->missing| | |
+--------------------------+ | v
| | +-----------------------------+
v | | Food types available |
+--------------------------+ | | - count types |
| Entertainment & Education| | | - if < required: missing |
| - check d.entertainment | | +-----------------------------+
| - check d.education | | |
| - update demands->missing| | v
+--------------------------+ | +----------------------------+
| Goods check: | | |
| | | - pottery, linen, jewelry |
+--------->>>>>>------| | - beer and wine |
| - update demands if miss |
+----------------------------+
|
v
+--------------------------+
| Return e_house_evolve |
| (if all passed) |
+--------------------------+E se la casetta era pronta per l'upgrade, partiva il cambio di texture e l'unione delle case vicine di dimensione 1 casella in un'unica grande casa 2x2.
Ancora un po' di codice
Anche in un gioco così vecchio gli sviluppatori dedicavano molta attenzione a un modello relativamente realistico del funzionamento della casa, che teneva conto di molti parametri. E altri 6 non sono riuscito a ricostruirli, perché nessun codice del gioco originale toccava quegli indirizzi: forse era qualcosa di debug.
Questo è l'insieme dei parametri su cui opera una casa a ogni frame
struct runtime_data_t : no_copy_assignment {
//e_house_level level;
uint16_t foods[8];
uint16_t inventory[8];
uint16_t highest_population;
uint16_t unreachable_ticks;
uint16_t last_update_day;
building_id tax_collector_id;
uint16_t population;
int16_t tax_income_or_storage;
uint8_t is_merged;
uint8_t booth_juggler;
uint8_t bandstand_juggler;
uint8_t bandstand_musician;
uint8_t pavillion_musician;
uint8_t pavillion_dancer;
uint8_t senet_player;
uint8_t magistrate;
uint8_t bullfighter;
uint8_t school;
uint8_t library;
uint8_t academy;
uint8_t apothecary;
uint8_t dentist;
uint8_t mortuary;
uint8_t physician;
uint8_t temple_osiris;
uint8_t temple_ra;
uint8_t temple_ptah;
uint8_t temple_seth;
uint8_t temple_bast;
uint8_t no_space_to_expand;
uint8_t num_foods;
uint8_t entertainment;
uint8_t education;
uint8_t health;
uint8_t num_gods;
uint8_t shrine_access;
uint8_t devolve_delay;
uint8_t bazaar_access;
uint8_t fancy_bazaar_access;
uint8_t water_supply;
uint8_t house_happiness;
uint8_t criminal_active;
uint8_t tax_coverage;
uint8_t days_without_food;
uint8_t hsize;
uint8_t unknown_00;
uint8_t unknown_01;
uint8_t unknown_02;
uint8_t unknown_03;
uint8_t unknown_04;
uint8_t unknown_05;
building_id worst_desirability_building_id;
xstring evolve_text;
};Quando tutti i parametri sono adatti all'upgrade, manca davvero poco per cambiare la texture. Dell'implementazione della vtable in plain-C ho già scritto qui, ma c'è un altro punto interessante: questa rigaauto &d = (building_house::runtime_data_t)b.runtime_data;
fa riferimento a un buffer di dati comune, condiviso tra tutti i tipi di edifici presenti nel gioco, che però si trasforma nei dati specifici su cui opera l'edificio esistente.
building g_all_buildings[5000];
class building {
public:
enum { max_figures = 4 };
using ptr_buffer_t = char[24];
private:
ptr_buffer_t _ptr_buffer = { 0 };
class building_impl *_ptr = nullptr; // dcast
public:
e_building_type type;
animation_context anim;
std::array<figure_id, max_figures> figure_ids;
char runtime_data[512] = { 0 };Una soluzione piuttosto elegante per l'epoca, che permetteva di ottenere una parvenza di virtualità e di avere funzioni sovraccaricate per ogni tipo di edificio (update, draw, handle etc), mantenendo però la velocità del C puro. Il motore di Pharaoh non era ancora completamente passato al C++, come avvenne in Zeus, dove si vedono già i nomi mangled di classi e strutture, ma non era nemmeno puro C, come in Caesar.
Lo svantaggio di questa soluzione con una cache comune era l'impossibilità di memorizzare più di 512 byte nella cache di un singolo edificio, ma credo che fosse molto più di quanto servisse davvero alle classi. Oggi uno schema del genere si potrebbe chiamare runtime_data, quando in un pool di blocchi di dimensione uguale si creano dati per strutture diverse; solo che qui questo pool ce lo portiamo sempre dietro.
Persino la classe più pesante, quella che descrive le piramidi, o meglio la sua cache, occupava solo 234 byte. Nel gioco originale la struttura di un edificio occupa 836 byte, il che con un array statico di 5000 edifici dava ~ 4,2Mb di RAM; considerando che il gioco è uscito nel 1999, quando la memoria media era di circa 64Mb nei sistemi normali e 128Mb in quelli da gioco, doveva bastare per una città molto grande. Purtroppo non sono ancora arrivato alle piramidi, ma le mastabe si possono già costruire, e la loro logica è praticamente la stessa.
Raramente una mappa riusciva a essere riempita con così tanti edifici. I vantaggi di questa soluzione: questo pezzo di memoria si può semplicemente salvare e rileggere senza preoccuparsi della serializzazione. E in generale, analizzando i sorgenti del gioco ottenuti col reverse, mi convinco sempre di più che l'abbiano scritto, come minimo, dei geni, capaci di far stare in volumi del genere (il gioco partiva con un minimo di 32Mb, e per di più con le texture) un ottimo city builder.
Torniamo all'aggiornamento della texture: qui il codice è già in C++ ed è mio, ma gran parte della logica è stata conservata. In particolare, è rimasto l'hardcode degli offset nelle texture per le case unite e per quelle singole.
void building_house::change_to(building &b, e_building_type new_type) {
auto &d = *(building_house::runtime_data_t*)b.runtime_data;
const int house_update_delay = std::min(house_up_delay(), 7);
const int absolute_day = game.simtime.absolute_day(true);
const bool can_update = (absolute_day - d.last_update_day < house_update_delay);
if (house_update_delay > 0 && (new_type > b.type) && can_update) {
return;
}
b.clear_impl(); // rimuove la vecchia impl
b.type = new_type;
auto house = b.dcast_house();
int image_id = house_image_group<false>(house->house_level());
const int img_offset = house->anim(animkeys().house).offset;
if (house->is_merged()) {
image_id += 4;
if (img_offset) {
image_id += 1;
}
} else {
image_id += img_offset;
image_id += map_random_get(b.tile) & (house->params().num_types - 1);
}
map_building_tiles_add(b.id, b.tile, b.size, image_id, TERRAIN_BUILDING);
d.last_update_day = game.simtime.absolute_day(true);
}
Questa riga invece si occupa delle texture casuali delle case singole, perché la città non sembri tutta uguale.
image_id += map_random_get(b.tile) & (house->params().num_types - 1);
Conclusione
Chiudo qui il racconto sugli attributi, un sistema che per molti anni è diventato il punto di riferimento del genere city builder e si è evoluto in giochi noti a molti come Stronghold, Anno, Patrician. Se a qualcuno è venuta nostalgia di un vecchio city builder, passate su github o su itch.io: i sorgenti sono pubblici e si possono giocare le prime 8 missioni. L'obiettivo del progetto è ricostruire il gioco originale (senza preservare l'autenticità dei sorgenti, come fa invece il progetto Julius), permettere di giocarci sulle piattaforme moderne e svilupparlo ulteriormente con mod e pacchetti grafici.
Tutti gli articoli