Resultat 1 till 4 av 4

Ämne: Entity Component System (ECS) och LUA - min okonventionella variant

  1. #1

    Spel Entity Component System (ECS) och LUA - min okonventionella variant

    Hej! Idag tänkte jag dela med mig av mitt senaste projekt, vilket är ett ECS-system med lite okonventionella lösningar jag har valt för att göra det så lätt som möjligt att jobba med.

    Det är ett ganska typiskt ECS-system: med entiteter, komponenter och system. De olika delarna i mitt system är följande:

    cEntity
    Klassen som representerar ett spelobjekt. En entitet är uppbyggd av komponenter. En entitet är ett subjekt som kan observeras av andra entiteter eller klasser i programmet (observer listener -mönstret).

    Alla filer i mappen files/entitys/ laddas automatiskt vid uppstart. Varje fil innehåller beskrivningen av ett spelobjekt och dess namn. När ett spelobjekt är laddat så kan jag skapa hur många instanser av spelobjektet som jag vill bara genom att använda spelobjektets namn. När ett spelobjekt laddas från en lua-fil så skapas alla komponenter som entiteten behöver och sparas i minnet. När jag sedan vill skapa en instans av spelobjektet så kopieras de färdigladdade komponenterna från minnet och fästs på en ny entitet. Det går alltså väldigt snabbt och kostnadseffektivt att skapa nya instanser av spelobjekt.

    Såhär kan en fil som beskriver ett spelobjekt se ut:
    Kod:
    entity_test =
    {
    	component_body =
    	{
    		position		= { 100000, 100000 },
    		size			= { 40000, 40000 },
    		direction		= 1
    	},
    	component_health =
    	{
    		health			= 100,
    		health_max		= 100,
    		health_regeneration	= 0,
    		physical_resistance	= 0,
    		magical_resistance	= 0
    	},
    	component_abilitys =
    	{
    		mana			= 100,
    		mana_max		= 100,
    		mana_regeneration	= 0,
    		attack_damage		= 15,
    		attack_speed		= 1000,
    		spell_amplification	= 0,
    		cast_speed		= 1000
    	},
    	component_buffs =
    	{
    	},
    	component_movement =
    	{
    		movement_speed		= 50000
    	},
    	component_controll_player =
    	{
    		move_up			= "W",
    		move_down		= "S",
    		move_left		= "A",
    		move_right		= "D"
    	}
    }
    Det spelar ingen roll vilken ordning man lägger till komponenterna, och man behöver inte fylla i alla fält i en komponent eller göra det i en specifik ordning.

    Och såhär ser det ut när jag vill skapa en instans av spelobjektet som är beskrivet i filen:
    Kod:
    cEntity *p_entity = gCore._getSystem< cEntitySystem >()->_getEntityFactory()._newEntity( "entity_test" );
    p_entity->_spawn();
    cComponent
    Komponenterna fungerar i huvudsak som datacontainers, men inte helt uteslutande. Vissa komponenter som t.ex. cComponent_ability har medlemsfunktioner som _silence( int DURATION, bool OVERRIDE ) för att hantera viss intern logik.

    Komponenter har data som ägs av varje instans av komponenten, men har även data som delas av alla instanser av samma variation av en komponent. Data som inte ändrar på sig i en komponent är pekare, och därför kan instanser av samma variation av en komponent ha pekare som pekar på gemensam data. Ett simpelt exempel vore denna fiktiva komponent:

    Kod:
    class cComponent_sprite: public cComponent
    {
    public:
    	float		m_scale;	// när komponenten kopieras så får den en kopia av värdet på m_scale
    	cSprite		*p_sprite;	// när komponenten kopieras så får den en kopia av en pekare som pekar på gemensam data
    };
    När en komponent laddas in från en fil så sparas den i minnet för att sedan kopieras och fästas på entiteter. När programmet avslutas så frigörs komponenten som är till för kopiering i minnet, och all gemensam data (pekarna) frigörs. En kopia frigör inte några pekare när den förstörs.

    Det som händer är kort och gott:
    1. En komponent Cmp_0 laddas in i minnet från en lua-fil: rätt komponent-klass skapas och laddar in data från filen genom medlemsfunktionen _load()
    2. Nya instanser av samma komponent skapas genom att kopiera Cmp_0 rakt av
    3. Programmet avslutas och medlemsfunktionen _free() körs för Cmp_0 vilket frigör allt minne komponenten har allokerat

    Såhär ser funktionen ut som skapar en entitet med tillhörande komponenter som redan är laddade:
    Kod:
    cEntity *cEntityFactory::_newEntity( cStringID NAME ) {
    
    	cEntity *p_entity = gCore._getSystem< cEntitySystem >()->_newEntity();
    
    	if( _preloadEntity( NAME ) )
    	{
    		componentv_t &l_components = *m_preloaded._getValue( NAME );
    
    		for( componentv_t::const_iterator c_itr = l_components.begin();
    			 c_itr != l_components.end(); ++c_itr )
    		{
    			// copy component
    			cComponent *p_newcomp = ( *c_itr )->_getCopy();
    			p_entity->_attachComponent( p_newcomp );
    		}
    	}
    
    	return p_entity;
    }
    En komponent ärver ett par grundläggande funktioner från basklassen cComponent. Såhär ser basklassen för en komponent ut:
    Kod:
    class cComponent
    {
    
    private:
    //--	Private Member Variables
    
    protected:
    //--	Protected Member Variables
    	cStringID		m_type;
    	cEntity			*p_owner;
    
    public:
    //--	Constructors/Destructors
    	cComponent( cStringID TYPE = "EMPTY" );
    	~cComponent();
    
    //--	Initializing
    	virtual bool	_load( std::string NAME );	// load all data required by component
    	void		_attach( cEntity *P_ENTITY );	// attach component to entity
    
    //--	Updating
    	virtual void	_update( int DELTA );
    
    //--	Events
    	virtual void	_onEntitySpawn();		// called from cEntity::_onSpawn()
    	virtual void	_onEntityDestroy();		// called from cEntity::_onDestroy()
    	virtual void	_onEntityDelete();		// called from cEntity::_free()
    
    //--	Setters
    
    //--	Getters
    	const cStringID	&const	_getType() const;
    	cEntity			*_getOwner() const;
    	virtual cComponent	*_getCopy() const;
    	virtual void		_pushLuaTable();
    
    //--	Cleaning
    	virtual void	_free();			// free all data used by component (shared too)
    };
    Funktionen _getCopy() är mycket viktig då den används för att skapa kopior av komponenten. _pushLuaTable() används för att exponera alla medlemsvariabler i en komponent för lua. Funktionen _load() och _free() används endast utav komponenten som skapas i syfte att bli kopierad. Argumentet NAME för funktionen _load() är namnet på ett table i lua som beskriver all data för komponenten.

    Notera att det finns en funktion _update(). Denna funktion går emot designen av ett ECS-system, men jag har valt att använda en sådan funktion ändå, då jag anser att det blir lättare att jobba med systemet på detta sätt. Funktionen används främst av komponenter som har interna timers.

    cSubsystemModule
    SubsystemModule's används för att uppdatera entiteterna och deras komponenter. Trots att vissa komponenter har en intern uppdatering via sin _update()-funktion så sköts den större delen av uppdateringarna av systemen. Det finns flera olika system som ansvarar för att uppdatera olika komponenter. Basklassen för ett system ser ut såhär:
    Kod:
    class cSubsystemModule
    {
    
    private:
    //--	Private Member Variables
    	std::vector< cStringID >	m_requirements;					// required components by entity
    	cStringID					m_name;
    
    protected:
    //--	Protected Member Variables
    	std::vector< cEntity* >		m_entitys;						// subsystem entitys
    
    public:
    //--	Constructors/Destructors
    	cSubsystemModule( cStringID NAME );
    	~cSubsystemModule();
    
    //--	Updating
    	virtual void	_update( int DELTA );						// update subsystem
    
    //--	Management
    
    //--	Events
    	void			_onEntitySpawned( cEntity *P_ENTITY );
    	void			_onEntityDeleted( cEntity *P_ENTITY );
    	virtual void	_onSubsystemAddEntity( cEntity *P_ENTITY );	// entity added to subsystem
    	virtual void	_onSubsystemRemoveEntity( cEntity *P_ENTITY );// entity removed from subsystem
    
    //--	Setters
    protected:
    	void			_addRequirement( cStringID COMPONENT_TYPE ); // add component type required by entity for system
    public:
    
    //--	Getters
    	cStringID		_getName() const;
    	std::vector< cEntity* >	*const _getEntitys() const;
    
    //--	Cleaning
    	virtual void	_free();
    };
    Vissa system har medlemsfunktioner som kan användas för att göra sökningar efter entiteter. Då alla system går att nå genom den globala klassen gCore så kan man få en pekare till ett visst system och sedan använda systemets speciella medlemsfunktioner. Såhär ser det ut om man vill hitta alla entiteter med kroppar inom ett visst område:
    Kod:
    vector< cHandle< cEntity > > l_searchResult = gCore._getSystem< cEntitySystem >()->_getSubsystems()._getSubsystem< cSubsystemModule_physics >( "SUBSYSTEM_MODULE_PHYSICS" )->_findEntitys( cReci( 0, 0, 100, 100 ) );
    cInteraction
    Nu kommer vi till delen där mitt ECS-system skiljer sig från andra. Vanligtvis så brukar man kommunicera med entiteter genom meddelanden, men jag har valt att göra det på ett annat sätt. Jag har stöd för events i mina entiteter, men jag har ändock valt att lösa interaktioner på ett annat sätt.

    Med meddelanden tänker man ungefär såhär: "Jag meddelar [ENTITET] att denne har tagit skada, [ENTITET] vet själv vad det innebär."
    Med min lösning tänker man såhär: "Jag skadar [ENTITET], jag vet hur man orsakar skada."

    Jag kallar det för "interactions". En interaction är en interaktion med en entitet. En interaktion påverkar en entitet på ett visst sätt, till skillnad från meddelanden som hanteras av en entitet på ett visst sätt. En interaktion beskrivs av en klass, basklassen ser ut såhär:
    Kod:
    class cInteraction
    {
    
    private:
    //--	Private Member Variables
    
    protected:
    //--	Protected Member Variables
    
    public:
    //--	Constructors/Destructors
    	cInteraction();
    	~cInteraction();
    
    //--	Initializing
    	virtual bool	_load();
    
    //--	Events
    	virtual void	_interact( cHandle< cEntity > ACTOR, cHandle< cEntity > TARGET );
    };
    En interaktion laddas från ett table i lua genom funktionen _load(). Sedan kan man använda interaktionen hur många gånger man vill genom funktionen _interact(). Om en entitet A vill skada entitet B så ser det ut såhär:
    Kod:
    cInteraction_damage l_damage;
    lua_getfield( "damage" );
    l_damage._load(); // interactions skapas och laddas egentligen i en fabrik
    l_damage( A, B );
    Interaktion med constructor:
    Kod:
    cInteraction_damage l_damage( 10, 10, 10 );
    l_damage( A, B );
    Logiken för vad som händer i en interaktion finns i funktionen _interact(). För klassen cInteraction_damage ser funktionen ut såhär:
    Kod:
    void cInteraction_damage::_interact( cHandle< cEntity > ACTOR, cHandle< cEntity > TARGET ) {
    
    	// quick access
    	cComponent_health *p_healthComponent = TARGET->_getComponent< cComponent_health >( "component_health" );
    	
    	// if target can take damage
    	if( !p_healthComponent )
    		return;
    
    	// deal damage
    	p_healthComponent->m_health -= m_physical * (float)( ( 100 - p_healthComponent->m_physical_resistance ) / 100.f );
    	p_healthComponent->m_health -= m_magical * (float)( ( 100 - p_healthComponent->m_magical_resistance ) / 100.f );
    	p_healthComponent->m_health -= m_pure;
    
    	if( p_healthComponent->m_health <= 0 )
    		p_healthComponent->m_health = 0;
    }
    Detta innebär alltså att komponenterna inte måste hålla koll på vad som kan göras mot dem, logiken för hur de påverkas av yttre händelser hanteras av interaktions-klasserna.

    Om t.ex. en förmåga i spelet orsakar en viss skada och sedan applicerar en buff på en entitet så kan förmågan äga instanser av alla interaktioner som sker när förmågan används. Samma interaktions-klasser kan då användas av förmågan för att påverka olika entiteter vid olika tillfällen. Effekten av en förmåga (alla interaktioner) kan laddas in i minnet i förväg, sedan används alla interaktioner en efter en på entiteten som är målet för effekten.

    Personligen tycker jag att detta är ett väldigt bra alternativ till komponenter som reagerar på meddelanden. På detta sätt så programmerar man reaktionen direkt istället för att låta entiteterna/komponenterna reagera själva.

    cBuff
    I mitt projekt så finns det buffar som påverkar entiteter med komponenten cComponent_buffs. En buff beskrivs också i en lua-fil och laddas automatiskt på samma sätt som entiteter. Det finns olika typer av buffar, men det jag ville visa var hur simpelt det är att använda interaktions-klasserna för att påverka entiteter.

    Såhär kan en buff-fil se ut:
    Kod:
    buff_test =
    {
    	type				= "ticking",
    	tick_time			= 1000,
    	num_ticks			= 5,
    
    	onApply = function( TARGET, SOURCE )
    
    		local health_component = get_component( TARGET, "component_health" )
    		print( "Health: " .. health_component.health )
    
    		entity_interact( TARGET, SOURCE, "damage", { magical = health_component.health / 2 } )
    	end,
    
    	onTick = function( TARGET, SOURCE )
    
    		entity_interact( TARGET, SOURCE, "damage", { physical = 10, magical = 5, pure = 2 } )
    		entity_interact( TARGET, SOURCE, "spawn", { name = "entity_test" } )
    	end,
    
    	onExpired = function( TARGET, SOURCE )
    
    		entity_interact( TARGET, SOURCE, "damage", { pure = 25 } )
    		entity_interact( TARGET, SOURCE, "stun", { duration = 500 } )
    	end,
    
    	onPurged = function( TARGET, SOURCE )
    
    		entity_interact( TARGET, SOURCE, "damage", { pure = 25 } )
    	end
    }
    Filen ovan är ett exempel på en buff som påverkar en entitet i 5 sekunder, där funktionen onTick() körs en gång varje sekund. Buffen orsakar skada av olika typer på den påverkade entiteten.

    Jag använder funktionen get_component() för att få tag i en kopia av datat i cComponent_health som tillhör entiteten som är påverkad av buffen. Sedan använder jag en funktion entity_interact vilken skapar en interaktions-klass av typen "damage" med ett lua-table som argument. Då interaktions-klassen laddar sin data från ett lua-table så behöver jag inte definera alla fält som beskriver interaktionen för att det ska fungera. Ta en titt på hur cInteraction_damage laddar sin data nedan:

    Kod:
    bool cInteraction_damage::_load() {
    
    	// quick access
    	cScriptSystem *p_script = gCore._getSystem< cScriptSystem >();
    
    	if( p_script->_getField( "physical" ) )
    	{
    		m_physical = p_script->_popInt();
    	}
    
    	if( p_script->_getField( "magical" ) )
    	{
    		m_magical = p_script->_popInt();
    	}
    
    	if( p_script->_getField( "pure" ) )
    	{
    		m_pure = p_script->_popInt();
    	}
    
    	p_script->_popValue();
    
    	return true;
    }
    Buffen använder olika interaktioner så som "stun", "spawn" och "damage", vilka har sin egen logik och applicerar den på entiteten.

    Med exemplet ovan så kan jag tyvärr inte spara interaktions-klasserna i minnet för att återanvändas, vilket innebär att varje gång en interaktion genom scriptet sker så skapas, laddas, och frigörs varje interaktions-klass. Jag har dock ett alternativt sätt att göra buffs på som undviker detta.


    Avslut
    Det blev en mycket längre post än vad jag hade tänkt. Såhär i efterhand så har jag blivit lite osäker på vad jag egentligen ville säga med posten. Men förhoppningsvis så kanske den kan fungera som en inspiration för någon.

    Diskussionsunderlag
    Vad är tankarna kring användandet av det jag kallar för "interactions" istället för meddelanden/events? Man slipper meddelande/event -köer och en interaktion av samma typ tar alltid lika lång tid att utföra, oberoende av hur många komponenter entiteten som utsätts för den har.

    Är det lättare eller svårare att programmera "interaktioner" istället för att bara skicka ett meddelande och programmera meddelandehanteringen för komponenterna?

    Är det det rimligt att kalla på en lua-funktion för varje event eller bör man till så stor del som möjligt sköta sådant datadrivet genom att t.ex. skapa containers med färdiga interaktionsklasser för varje event?
    Senast redigerat av pponmm den 2017-10-23 klockan 14:38.

  2. #2
    Efter många års erfarenhet har jag kommit att dela många av dina slutsatser. En av dom viktigare är väl separationen från data och logik, d.v.s att du sliter ut logiken och lägger in dom i Interaction-klasser.
    Mitt tillvägagångsätt är lite annorlunda, jag har en anpassning av MVC, där jag lagt till en statemachine som delar in spelet i högnivå stater, dvs menuState, configState, mapSelectionState, playingState, scoreState etc. Dessa states skapar data i modellen, eller så skapar dom controller-klasser som sköter om all logik för ett spelkobjekt.

    Jag har även ett liknande system för dina data-modeller, men istället för Lua script använder jag JSON.

  3. #3
    Blev så inspirerad av din post att jag gjorde en liten artikel om mitt senaste eventsystem, tyvärr verkade inte svenska tecken fungera:
    http://indiegamedev.se/content.php?1...e-Event-System

  4. #4
    Har nyligen börjat använda Lua (igen, men blev aldrig speciellt bra på det sist jag använde det för ett par år sedan), iom. Fantasy Consoles. De spelen är om möjligt ännu mindre än dem jag vanligtvis gör, så känner inte direct något behov av att använda ett ECS där, men fann ändå detta klart intressant.
    Senast redigerat av Kristoffer den 2017-10-26 klockan 00:41.

Bokmärken

Behörigheter för att posta

  • Du får inte posta nya ämnen
  • Du får inte posta svar
  • Du får inte posta bifogade filer
  • Du får inte redigera dina inlägg
  •