Il software, ispirato alla modalità Robo-Rumble di Brawl Stars della Supercell, è un gioco 2d top-down il cui obiettivo è quello di difendere per più tempo possibile il centro della mappa e sopravvivere agli attacchi continui di orde nemiche.
- La partita termina in caso di esaurimento dei punti vita del giocatore o della cassa gialla.
- Il player:
- deve essere controllabile tramite tastiera nelle quattro direzioni.
- deve essere in grado di sparare proiettili ai nemici in una direzione scelta tramite il mouse.
- I nemici:
- nascono ad ondate.
- attaccano la cassa o il giocatore.
- Il livello di difficoltà cresce man mano che il gioco avanza.
- Il gioco è top-down con visuale dall'alto.
- L'ambiente di gioco è delimitato e include ostacoli fisici che il giocatore può usare come copertura.
- Il gioco si presenta con una grafica minimale composta da figure geometriche.
- L'applicazione si presenta con un menu iniziale.
- Il software:
- deve essere eseguibile in modo fluido.
- deve essere compatibile con una varia gamma di hardware e sistemi operativi (Linux, Windows e MacOS).
Il gioco ha luogo all'interno di una mappa, la quale contiene il giocatore, i nemici e gli oggetti permanenti (e.g. muri e cassa centrale). Il player e i nemici devono potersi muovere liberamente all'interno di essa, secondo la volontà del giocatore oppure in base ad un'AI stabilita e possono sparare proiettili per cercare di danneggiare il proprio obiettivo. Il player, i nemici e la cassa infatti hanno un quantitativo di punti vita e all'esaurimento di questi l'entità muore.
classDiagram
class GameEntity {
<<interface>>
}
GameEntity <|.. Enemy
GameEntity <|.. Player
GameEntity <|.. Chest
GameEntity --* World
World *-- Map
Map *-- Obstacle
Enemy *-- IA
Enemy <--> Player : attack
Enemy <--> Chest : attack
Player --> GameOver
Chest --> GameOver
L'architettura del nostro programma è quella del tipico Model View Controller. L'applicazione si compone di un controller composto da una singola view che svolge il ruolo di visualizzare gli elementi passati dal controller e di ricevere input dalle periferiche e passarle al controller. Il controller gestisce il main loop del gioco e il passaggio degli input dalla view al model, e dei dati da stampare (sotto forma di GraphicsComponent) dal Model alla View. Per questo motivo View e Model sono indipendenti tra di loro e si affidano unicamente al controller. Facendo riferimento alle interfacce richieste è possibile cambiare tecnologia della View facilmente in quanto è necessario solo definire la finestra, i metodi per stampare le forme richieste dal gioco (in ViewAPI) e notificare il controller degli input. Il controller richiama l'update del model, gli passa gli input, prende da lui i componenti grafici che devono essere stampati e ne legge lo stato del gioco. Per questo motivo è anche facile cambiare il model dell'applicazione e riusare il resto dell'applicazione per altri giochi.
classDiagram
direction LR
class World {
<<interface>>
+isOver(): boolean
+update(deltaTime: long): void
+getGraphicsComponents(): List~GraphicsComponent~
+processInput(event: InputEvent): void
}
class Engine {
<<interface>>
+initialize(): void
+mainLoop(): void
+changeStatus(status: EngineStatus): void
+terminate(): void
}
class EngineView {
<<interface>>
+render(gComponentsList: List~GraphicsComponent~): void
+changePage(panel: Pages): void
+close(): void
}
Engine *--* EngineView
World --* Engine
class EngineStatus {
<<enum>>
MENU
GAME
DEATH_SCREEN
TERMINATED
PAUSE
}
Engine --> EngineStatus
class Pages {
<<enum>>
MENU
GAME
DEATH_SCREEN
PAUSE_SCREEN
}
EngineView --> Pages
Alcuni dei nostri schemi UML potrebbero mostrare unicamente le interfacce interessate al design che vogliamo mostrare, questo perché abbiamo cercato di applicare il pattern Strategy il più possibile per poter rendere ogni elemento dell'applicazione modificabile senza dover modificare altre parti del codice, di conseguenza le sole interfacce sono spesso sufficienti a spiegare il design del progetto.
Quando nei diagrammi utilizzeremo collegamenti propri delle classi concrete tra interfacce è invece per indicare che l'effettiva implementazione di tale interfaccia avrà quel tipo di collegamento con l'implementazione dell'altra, omettiamo di mostrare le classi concrete per semplice chiarezza e facilità di lettura del diagramma.
classDiagram
class EntityFactory {
<<interface>>
}
class EntityManager {
<<interface>>
+ getEntities() : Set<CachedGameEntity>
+ clean()
}
EntityFactory <|-- EntityManager
class EntityManagerImpl {
-factory : EntityFactory
+ getEntities() : Set<CachedGameEntity>
+ clean()
}
EntityManager <|.. EntityManagerImpl
EntityFactory --* EntityManagerImpl
Problema: creare facilmente entità senza fare troppe ripetizioni di codice. In particolare bisogna fare in modo che chiunque voglia creare un'entità di gioco non deve occuparsi di aggiungerla da qualche parte.
Soluzione: dato che era già stata predisposta la creazione di una factory di entità (realizzata da Luca Marchi) si è ritenuto opportuno creare una struttura che tenesse in memoria le entità generate e si occupasse di distruggerle qual'ora ce ne fosse stato il bisogno, ovvero l'EntityManager. Allo scopo è stato utile ricorrere al pattern Decorator, che permette al manager di essere riconosciuto come una factory di entità, ma con funzioni aggiuntive per ottenere tutte le entità in gioco e per fare "pulizia" di quelle che vanno eliminate.
classDiagram
class Spawner {
<<interface>>
+ spawn()
}
class AbstractSpawner {
<<abstract>>
+ spawn()*
}
class EntityFactory{
<<interface>>
}
World *-- Spawner
Spawner <|.. AbstractSpawner
Spawner *-- EntityFactory
AbstractSpawner <|-- AnonymousSpawner1
AbstractSpawner <|-- AnonymousSpawner2
AbstractSpawner <|-- AnonymousSpawner3
Problema: creare un meccanismo di spawning efficiente e modulare, di modo che sia facile far cominciare un nuovo livello.
Soluzione: al fine di risolvere il problema, si è ritenuto utile creare un oggetto apposito che si occupasse
solamente di generare nuovi nemici. Lo Spawner, infatti, al richiamo di spawn()
genera un nuovo nemico secondo dei criteri stabiliti
a priori. Di fatto, dunque, quando comincia un nuovo livello il resposabile della gestione di gioco attiva l'azione su tutti gli spawner.
Questo comportamento si avvicina molto al pattern Command, in cui l'azione execute()
viene rinominata spawn()
per chiarezza d'uso.
Allo stesso tempo, si differenzia dal pattern per la presenza di funzionalità aggiuntive per la gestione della difficoltà e del tipo dello spawner.
classDiagram
class SpawnerFactory {
<<interface>>
+ createLinear(Position2D position, Set<ActiveEntity> targets) : Spawner
+ createScalar(Position2D position, Set<ActiveEntity> targets) : Spawner
+ createBoss(Position2D position, Set<ActiveEntity> targets) : Spawner
}
class SpawnerFactoryImpl {
+ createLinear(Position2D position, Set<ActiveEntity> targets) : Spawner
+ createScalar(Position2D position, Set<ActiveEntity> targets) : Spawner
+ createBoss(Position2D position, Set<ActiveEntity> targets) : Spawner
}
class Spawner{
<<interface>>
}
SpawnerFactory <|.. SpawnerFactoryImpl
SpawnerFactory ..> Spawner : creates
Oltretutto c'è anche la necessità di avere spawner che generano entità con proprietà diverse, come statistiche che incrementano col tempo oppure boss. A tal proposito si è utilizzato il pattern Factory methods, in cui
un oggetto si occupa della creazione di Spawner definendo i criteri per l'azione spawn()
classDiagram
class GameTile{
<<interface>>
+ getPosition() : Position2D
+ getDimension() : int
+ getType() : TileType
+ contains(Position2D pos) : boolean
}
class GameMap {
<<interface>>
+ getPlayerSpawn() : Position2D
+ getChestPosition() : Position2D
+ getObstaclesPositions() : Set<Position2D>
+ getSpawnersPositions() : Map<Position2D, SpawnerType>
+ getTileGraph() : Graph
+ searchTile(Position2D pos) : GameTile
}
class GameTileImpl{
+ getPosition() : Position2D
+ getDimension() : int
+ getType() : TileType
+ contains(Position2D pos) : boolean
}
class GameMapImpl {
+ getPlayerSpawn() : Position2D
+ getChestPosition() : Position2D
+ getObstaclesPositions() : Set<Position2D>
+ getSpawnersPositions() : Map<Position2D, SpawnerType>
+ getTileGraph() : Graph
+ searchTile(Position2D pos) : GameTile
}
GameTile *-- Position2D
World *-- GameMap
GameMap *-- GameTile
GameMap <|.. GameMapImpl
GameTile <|.. GameTileImpl
Problema: suddividere la mappa per poter permettere ai nemici di seguire un percorso verso il loro obbiettivo.
Soluzione: l'idea è stata quella di suddividere la mappa di gioco in delle celle chiamate GameTile, di cui ne è composta. Ogni cella ha delle proprietà, come una posizione, una dimensione e un tipo e riesce a determinare se una data entità di gioco è contenuta al suo interno. La mappa invece è passiva, cioè contiene i criteri per il posizionamento degli oggetti nel gioco, ad esempio la posizione della cassa, dei muri o degli spawner. Inoltre, alla sua creazione si occupa di creare un grafo delle GameTile libere, che verrà sfruttato dalla AI dei nemici per permettere loro di inseguire un certo target.
classDiagram
class GraphicsComponent {
<<interface>>
+getColor(): EngineColor
+getPositions(): List<Position2D>
+setColor(color: EngineColor): void
+setPositions(pos: List<Position2D>): void
+getType(): GraphicType
}
class GraphicType {
<<enum>>
WORLD
HUD
}
class EngineColor {
<<interface>>
+BLUE: EngineColor
+RED: EngineColor
+GREEN: EngineColor
+WHITE: EngineColor
+BLACK: EngineColor
+YELLOW: EngineColor
+getR(): int
+getG(): int
+getB(): int
}
GraphicsComponent --> GraphicType
GraphicsComponent --> EngineColor
class PolygonGraphicsComponent {
<<interface>>
}
PolygonGraphicsComponent --|> GraphicsComponent
class StatusBarGraphicsComponent {
<<interface>>
+setStatusColor(statusColor: EngineColor): void
+setDimension(base: int, height: int): void
+setRange(min : int , max : int) : void
+setStatus(i: int): void
+getStatusColor(): EngineColor
+getBase(): int
+getHeight(): int
+getMax(): int
+getMin(): int
+getStatus(): int
+getPercentage(): double
}
StatusBarGraphicsComponent --|> GraphicsComponent
class TextGraphicsComponent {
<<interface>>
+getText(): String
+setText(text: String): void
+setSize(size: int): void
+getSize(): int
}
TextGraphicsComponent --|> GraphicsComponent
Problema: Creare oggetti che rappresentino l'aspetto grafico di elementi del modello del gioco, in maniera indipendente dalla tecnologia della View.
Soluzione: L'interfaccia GraphicsComponent rappresenta un componente grafico generico, questa può essere specializzao in più tipi: Testo, Poligono, Barra di stato, etc.
- Il model dell'applicazione si occupa di creare e aggiornare questi oggetti per dire che aspetto devono assumere gli elementi del gioco.
- Avendo definito tutte queste proprietà, tra cui i colori e che tipo di elemento grafico sono (se parte del mondo di gioco o della HUD), questi componenti grafici sono totalmente indipendenti dalla tecnologia di View adottata.
classDiagram
class RenderableGraphicComponent {
<<interface>>
+getRenderer(): GraphicsComponentRenderer
}
RenderableGraphicComponent --|> GraphicsComponent
class GraphicsComponentRenderer {
<<interface>>
+render(g: Graphics2D, camera: ViewCamera): void
+setComponent(gComp: GraphicsComponent): void
}
RenderableGraphicComponent *-- GraphicsComponentRenderer
GraphicsComponentRenderer *-- GraphicsComponent
class GraphicsComponent {
<<interface>>
}
Problema: Permettere alla view di leggere questi componenti grafici e rappresentarli a schermo.
Soluzione: Per facilitare la view nell'interpretare questi oggetti è stata definita un'interfaccia che estende GraphicsComponent e che permette di contenere un oggetto che si occupa di renderizzare questi componenti grafici, specifico per la tecnologia usata.
- Il GraphicsComponentRenderer è dunque unico per la tecnologia di View usata: è legato ad un solo componente grafico e alla chiamata del metodo
render
lo stampa a schermo avendo il riferimento alla Camera e agli oggetti necessari per effettuare la stampa a video. - Per l'implementazione della view alla fine i componenti grafici che le vengono passati si comportano similmente al pattern Command: ottenuto il set di Componenti grafici filtra quelli che è capace di interpretare (chi implementa RenderableGraphicComponent) e li stampa a schermo chiamando il metodo
render
.
classDiagram
class GraphicsComponentsFactory {
<<interface>>
+polygon(color: EngineColor, pos: List<Position2D>, type: GraphicType): PolygonGraphicsComponent
+text(color: EngineColor, pos: Position2D, size: int, text: String, type: GraphicType): TextGraphicsComponent
+statusBar(bgColor: EngineColor, statusColor: EngineColor, pos: Position2D, base: int, height: int, type: GraphicType): StatusBarGraphicsComponent
}
GraphicsComponentsFactory --> TextGraphicsComponent
GraphicsComponentsFactory --> PolygonGraphicsComponent
GraphicsComponentsFactory --> StatusBarGraphicsComponent
GraphicsComponentsFactory --> RenderableGraphicComponent
class RenderableGraphicComponent {
<<interface>>
}
class PolygonGraphicsComponent {
<<interface>>
}
class StatusBarGraphicsComponent {
<<interface>>
}
class TextGraphicsComponent {
<<interface>>
}
Problema: creare facilmente i componenti grafici.
Soluzione: Per permettere al Model dell'applicazione di creare i componenti grafici senza dover sapere dell'associazione di questi coi GraphicsComponentRenderer è stata creata una factory che restituisse il tipo di componente grafico richiesto con già incorporato il renderer della view necessario per permetterne la stampa.
classDiagram
class ViewCamera {
<<interface>>
+getScreenPosition(pos: Position2D, type: GraphicType): Position2D
+getScreenPositions(pos: List<Position2D>, type: GraphicType): List<Position2D>
+setScreenDimension(width: double, height: double): void
+getHoriOffset(): double
+getVertOffset(): double
+getSizeRatio(): double
}
class WorldCamera {
<<interface>>
+moveTo(position: Position2D): void
+getWorldPosition(pos: Position2D): Position2D
+centerOn(position: Position2D): void
+setArea(height: int, width: int): void
+keepInArea(position: Position2D): void
}
class CameraMover {
<<interface>>
+installCamera(camera: WorldCamera): void
}
CameraMover --> WorldCamera
class CameraViewer {
<<interface>>
+installCamera(camera: ViewCamera): void
}
CameraViewer --> ViewCamera
class Camera {
<<interface>>
}
Camera --|> WorldCamera
Camera --|> ViewCamera
CameraImpl ..|> Camera
EngineImpl *-- CameraImpl
World ..|> CameraMover
World *-- CameraImpl
ViewImpl *-- CameraImpl
ViewImpl ..|> CameraViewer
Problema: Sullo schermo va visualizzata solo una parte del mondo di gioco, deve mantere le proporzioni corrette per qualunque formato di schermo e la porzione visibile deve essere scelta dal Model dell'applicazione.
Soluzione: L'engine si occupa di creare un oggetto (nel progetto la classe "CameraImpl") che si occupa di effettuare i calcoli di conversione da coordinate del mondo a coordinate sullo schermo e viceversa. L'engine darà il riferimento a questo oggetto al World e all'EngineView che lo interfacceranno tramite delle interfacce specifiche per quello che ci possono fare:
- Il world ha accesso ai metodi che gli permettono di spostare la telecamera e di ottenere le coordinate del mondo a partire da quelle sullo schermo (nel caso di quando si riceve input dal puntatore).
- L'EngineView ha accesso ai metodi di conversione delle coordinate da world a schermo, metodi per informare la telecamera delle dimensioni della finestra e per ottenere informazioni sulle proporzioni della schermata di gioco (per poter generare bande nere a bordo schermo se necessario).
- al momento della stampa l'EngineView passa il riferimento della ViewCamera ai GraphicsComponentRenderer che si occupano della effettiva stampa a schermo.
Non è stato adottato alcun design pattern particolare se non lo Strategy: le diverse interfacce definiscono infatti una classe Camera generica che può avere l'implementazione che vogliamo, quella adottata nel nostro gioco è una implementazione standard, ma potremmo adottarne una che segue una logica diversa andando a distorcere le cose visualizzate a schermo senza dover modificare in alcun modo le classi che usano la telecamera.
classDiagram
class InputEventListener {
<<interface>>
+notifyInputEvent(event: InputEvent): void
}
class InputEventProvider {
<<interface>>
+setInputEventListener(listener: InputEventListener): void
+setInputEventFactory(factory: InputEventFactory): void
}
ViewImpl *-- EngineImpl
EngineImpl ..|> InputEventListener
ViewImpl ..|> InputEventProvider
Problema: Notificare il controller delle azioni dell'utente sulle periferiche.
Soluzione: Usando il pattern Observer abbiamo reso l'implementazione del controller un osservatore di "InputEvent". La view è il provider, il creatore di questi InputEvent e si occupa di notificare il controller quando uno di questi avviene.
classDiagram
ViewImpl *-- InputEventFactoryImpl
ViewImpl ..|> InputEventProvider
class InputEventProvider {
<<interface>>
}
InputEventProvider --> InputEventFactory
InputEventFactoryImpl ..|> InputEventFactory
class InputEventFactory {
<<interface>>
+filterKeyValue(key: int): Optional<Value>
+pressedValue(value: Value): InputEvent
+releasedValue(value: Value): InputEvent
+mouseDownAtPosition(position: Position2D): InputEvent
+mouseUpAtPosition(position: Position2D): InputEvent
}
InputEventFactory --> InputEvent
class InputEvent {
<<interface>>
+getType(): Type
}
class Type {
<<enum>>
ACTIVE
INACTIVE
}
InputEvent *-- Type
class InputEventPointer {
<<interface>>
+getPosition(): Position2D
}
InputEventPointer --|> InputEvent
class InputEventValue {
<<interface>>
+getValue(): Value
}
class Value {
<<enum>>
UP
DOWN
LEFT
RIGHT
RESET
PAUSE
}
InputEventValue *-- Value
InputEventValue --|> InputEvent
Problema: Creare una classe di oggetti che rappresenti gli eventi di input indipendentemente dalla tecnologia di View adottata.
Soluzione: L'interfaccia InputEvent rappresenta un evento di Input che può essere ricevuto da un InputEventListener. Questa interfaccia viene poi estesa in tipologie secondarie che vanno a specificare un tipo particolare di input come quelle di un puntatore o di un determinato valore.
- Per permettere al provider di generare questi oggetti facilmente abbiamo creato una Factory di eventi che gli viene fornita.
- E' grazie al metodo
filterKeyValue
della factory che i tasti della tastiera vengono convertiti in valori che rappresentano un'azione di gioco. - Sarà compito del listener filtrare i vari input che riceve per vedere cosa gli interessa e agire di conseguenza.
classDiagram
class Weapon {
<<interface>>
+setParentEntity(parent: Character): void
+attack(): void
}
class WeaponFactory {
<<interface>>
+pair(weapon: Weapon, character: Character): void
+simpleGun(reloadTime: long, shootSpeed: long, projectileSize: double, damage: int, projectileFactory: EntityFactory): Weapon
+simpleGunPairing(reloadTime: long, shootSpeed: long, projectileSize: double, damage: int, projectileFactory: EntityFactory, character: Character): Weapon
}
WeaponFactory --> Weapon
Problema: Creare un'arma che possa essere facilmente associata ad un unico personaggio.
Soluzione: Una Factory di Weapon fornisce metodi per creare facilmente il tipo di arma desiderato e per associarla ad un personaggio. La armi implementano l'interfaccia Weapon che identifica un tipo di arma generico (pattern Strategy), questo permette di creare armi con comportamenti diversi senza dover modificare il codice che le utilizza (questo non è stato fatto nel progetto solo per questione di tempo): basta creare nuove implementazioni di Weapon e aggiungere i metodi nella factory.
classDiagram
class EntityFactory {
<<interface>>
+createProjectile(team: Character.CharacterType, position: Position2D, direction: Vect, projectileSize: double, damage: int): Projectile
}
EntityFactory --> Projectile
SimpleGun *-- EntityFactory
SimpleGun ..|> Weapon
class Projectile {
<<interface>>
+setDamage(damage: int): void
}
class ActiveEntity {
<<interface>>
}
Projectile --|> ActiveEntity
class Weapon {
<<interface>>
}
Problema: Creare un'arma che possa sparare dei proiettili.
Soluzione: SimpleGun implementa l'interfaccia Weapon e fa riferimento ad una EntityFactory per generare nuovi proiettili; questi infatti sono delle ActiveEntiy, entità che si possono muovere e collidere con altre. SimpleGun alla creazione accetta parametri per impostare la velocità di sparo, la dimensione dei proiettili, la loro velocità e danno. Dunque solo da SimpleGun si potrebbero creare diversi tipi di armi.
classDiagram
BaseMovingEntity <|-- AbstractActiveEntity
MovingEntity <|.. BaseMovingEntity
AbstractActiveEntity <|-- CharacterImpl
AbstractActiveEntity <|-- Chest
AbstractActiveEntity <|-- Projectile
Weapon --* CharacterImpl
<<Interface>> MovingEntity
<<Interface>> Weapon
class AbstractActiveEntity{
<<Abstract>>
-health : int
+getHealth() : int
+takeDamage(int damage) : void
+isAlive() : boolean
}
class CharacterImpl{
-weapon :: Weapon
+makeDamage() : void
+setWeapon() : void
+pointTo(Position2D target) : void
+getType() : CharacterType
}
Problema: Progettazione delle diverse entità di gioco (in particolare quelle dotate di vita). Il design deve essere tale da dividere in maniera chiara le entità e i loro ruoli, inoltre deve garantire una facile estendibilità per modifiche future evitando la ripetizione di codice.
Soluzione: Per risolvere il problema di design delle entità è stato utilizzato il pattern Composite, dove la struttura ad albero (tipica di questo pattern) ha come radice CachedGameEntity
, da cui tutte le altre entità ereditano le caratteristiche comuni.
Nella gerarchia del Composite l' AbstractActiveEntity
, ovvero entità in grado di muoversi e dotata di vita, eredita il movimento estendendo BaseMovingEntity
e implementa il componente vita. Le entità di tipo CharacterImpl
, invece, rappresentano il player ed i nemici, quindi sono dotate di un'arma per poter infliggere danni. Questo tipo di design consente di estendere facilmente le funzionalità del gioco, rendendo possibile l'aggiunta di nuove entità o di nuovi comportamenti evitando di duplicare porzioni di codice. Ad esempio se si volesse creare un nuovo oggetto dotato di vita, basterebbe estendere la classe astratta AbstractActiveEntity
(la quale definisce un'implementazione di base dei metodi relativi alle entità con la vita), e quest'ultimo risulterebbe una foglia dell'albero del Composite.
Il difetto di questo design è che non si possono creare entità con la vita senza il movimento. Per risolvere questo problema si sarebbe potuto realizzare una classe intermedia la quale avrebbe fornito esclusivamente la vita. In questo modo però, AbstractActiveEntity
avrebbe dovuto estendere sia quest'ultima classe, sia BaseMovingEntity
. Rendendo il movimento un campo opzionale si è risolto il problema, permettendo di creare entità dotate di vita, ma che non si possono muovere come Chest
(foglia del composite).
classDiagram
Character <|.. CharacterImpl
ActiveEntity <|-- Character
ActiveEntity <|.. AbstractActiveEntity
CharacterType <-- Character
<<Interface>> ActiveEntity
<<Interface>> Character
class CharacterType{
<<Enumeration>>
PLAYER
SHOOTER
RUNNER
}
Inoltre viene utilizzato il pattern Strategy tramite l'utilizzo di interfacce. Questo permette di aggiungere diverse implementazioni, diverse "strategie" e di poter scegliere la più opportuna a runtime.
classDiagram
EntityFactory <|.. EntityFactoryImpl
EntityFactoryImpl *-- VertexCalculator
VertexCalculator <|.. VertexCalculatorImpl
<<Interface>> EntityFactory
<<Interface>> VertexCalculator
class EntityFactory{
createPlayer(double sideLength, Position2D position, int health, InputMovement movement) : Character
createRunner(GameEntity target, double sideLength, Position2D position, int health, GameMap map) : Character
createShooter(GameEntity target, double sideLength, Position2D position, int health, GameMap map) : Character
createObstacle(double sideLength, Position2D position) : CachedGameEntity
createChest(double sideLength, Position2D position, int health) : ActiveEntity
createProjectile(Character.CharacterType team, Position2D position, Vect direction, double projectileSize, int damage) : Projectile
}
class EntityFactoryImpl{
- VertexCalculator vertexCalculator
}
class VertexCalculator{
traingle(double sideLength, Position2D position) : List~Position2D~
square(double sideLength, Position2D position) : List~Position2D~
reactangle(double width, double height, Position2D position) : List~Position2D~
}
Problema: Nel videogame sono presenti molteplici entità di gioco, tra cui player, nemici, ostacoli, proiettili, ecc. Ogni tipo di entità ha comportamenti e attributi diversi. Questi oggetti devono poter essere istanziati comodamente nel World e nelle altre classi che li utilizzano, così da disporli nella mappa. Se si utilizzasse direttamente l'operatore 'new' nella creazione di oggetti ciò potrebbe portare ad una dipendenza diretta tra il codice che lo istanzia e la classe dell'oggetto stesso.
Soluzione: Utilizzando il design pattern Factory Method viene risolto il problema:
- Questo pattern separa il codice che crea gli oggetti dalla loro implementazione concreta, garantendo una migliore divisione delle responsabilità nel codice.
- I metodi della Factory (nel codice rappresentata da EntityFactory) consentono di istanziare vari tipi di entità in maniera flessibile. Così facendo la classe client può richiedere l'oggetto desiderato senza dover conoscere i dettagli implementativi.
- Delegando la logica creazionale alla Factory inoltre vengono ridotte le dipendenza tra la classe client e la classe concreta dell'oggetto. Si è preferito scegliere questo pattern rispetto ad un Builder perchè in questo caso i dettagli necessari alla creazione dell'oggetto sono noti a priori, ogni metodo della factory crea un tipo specifico di oggetto con parametri ben definiti. Istanziare oggetti step-by-step con numerosi passaggi, come fornisce il pattern Builder, non era adeguato. La classe EntityFactoryImpl utilizza una classe utilità per calcolare le coordinate dei vertici che sono necessari alla creazione di oggetti. Quest'ultima classe, VertexCalculatorImpl, si occupa esclusivamente del calcolo dei vertici di diverse figure geometriche.
classDiagram
BaseMovement <|-- BfsMovement
Character --* BfsMovement
BfsMovement *-- GameEntity
class BfsMovement{
- target : GameEntity
- agent : Character
+ update() : void
}
Problema: All'interno del gioco è necessario gestire il movimento dei nemici. Questi ultimi devono muoversi verso il player, oppure verso la chest e fermarsi una volta giunti a destinazione.
Soluzione: Per risolvere il problema del movimento dei nemici, viene utilizzato un algoritmo di ricerca Breadth-First Search (BFS), che è efficace ed efficiente nel trovare il percorso più breve tra due punti (nel codice rappresentati dal centro di due GameTile) in un grafo non pesato.
L'algoritmo di ricerca viene implementato nella classe BfsMovement
la quale estende BaseMovement
. Ogni volta che il metodo update() viene chiamato, se il nemico (agent), che viene preso nel costruttore della classe, non ha ancora raggiunto il suo obiettivo, si sposta verso il nodo successivo in direzione del target (Player o Chest).
classDiagram
GameEntity <|-- CachedGameEntityImpl
CachedGameEntityImpl <|-- BaseMovingEntity
CachedGameEntityImpl <|-- Obstacle
CachedGameEntityImpl *-- GameEntityImpl
GameEntity <|-- GameEntityImpl
Position2D --* GameEntityImpl
<<Interface>> GameEntity
class GameEntityImpl{
List~Position2D~ vertexes
Position2D position
+getHitbox() : Hitbox
+getAllCollided(Set~GameEntity~ gameObjects) : Set~GameEntity~
}
class CachedGameEntityImpl{
Hitbox hitbox
GameEntity entity
+reset()
}
Problema: Il gioco coinvolge esclusivamente entità con un corpo fisico e ogni entità può avere delle collisioni con le altre. Gestire queste collisioni tra queste entità può diventare complesso a livello computazionale se fatto in modo inefficente, ogni entità condivide poi buona parte del codice con le altre e solitamente differisce solo per pochi metodi.
Soluzione: Per risolvere questo problema è implementato il pattern proxy. In questo modo è possibile controllare la hitbox delle varie entità senza doverla ricalcolare ogni volta, quindi è possibile inserire anche un gran numero di entità all'interno di una singola scena senza che venga richiesto uno sforzo computazionale eccessivo.
La motivazione principale che spinge a questa soluzione è l'ottimizzazione delle collisioni: il proxy CachedGameEntityImpl
memorizza le informazioni sulla hitbox dell'entità. Questo permette di ricalcolare le collisioni e le hitbox solo dopo che le entità effettuino un movimento. Qualora si volesse utilizzare GameEntity
al posto di CachedGameEntity
è sufficente rinominare la classe Cached Game entity in game entity dove necessario e rimuovere dalla classe principale world l'utilizzo del metodo reset.
classDiagram
CachedGameEntity *-- Hitbox
Hitbox <|-- HitboxImpl
Hitboxes <|-- HitboxesImpl
HitboxImpl *-- Polygon
Hitboxes --> Hitbox
<<Interface>> Hitbox
<<Interface>> Hitboxes
class Hitbox{
getPolygonalHitbox() : Polygon
getVertexes() : List~Coordinates~
}
class HitboxImpl{
Polygon
}
class Hitboxes{
isColliding(Hitbox collider, Hitbox coollided) : boolean
rotate(List~Position2D~ polygon, double theta, Position2D center) : List~Position2D~
rotateTo(List~Position2D~ polygon, Vect pointingDir, Position2D center, Position2D target) : List~Position2D~
}
Problema: Ogni entità deve avere una hitbox con cui possa interfacciarsi con le altre e rilevare le collisioni.
Soluzione: Per risolvere questo problema, ho deciso di utilizzare il pattern facade. Questo pattern mi permette di fornire un'interfaccia semplificata per l'utilizzo delle hitbox all'interno del gioco.
classDiagram
CollisionCommand <|.. AbstractCollisionCommand
AbstractCollisionCommand *-- MovingEntity
AbstractCollisionCommand *-- GameEntity
AbstractCollisionCommand <|-- PushAwayCommand
AbstractCollisionCommand <|-- SlideCommand
<<Interface>> CollisionCommand
class CollisionCommand{
execute()
}
class AbstractCollisionCommand{
<<Abstract>>
collider : MovingEntity
collided : GameEntity
AbstractCollisionCommand(collider, collided)
}
Problema: Nel contesto del gioco avvengo numerose collisioni, tuttavia la gestione delle collisioni tra le entità possono variare in base al tipo di collisione. Quindi bisogna progettare un sistema che consenta modifiche specifiche al movimento in base al tipo di collisione.
Soluzione: Per risolvere il problema si è scelto di utilizzare il pattern Command. Questo pattern consente di variare le azioni da intraprendere a seguito di una collisione in base alla classe contreta che estende il command.
CollisionCommand
è l'interfaccia che definisce il metodo execute che sarà implementato dalle classi concrete per eseguire azioni specifiche a seguito di una collisione. In PushAwayCommand
e in SlideCommand
la direzione del collider, che è una moving entity, viene modificata in base alla posizione dell'altra entità.
classDiagram
MovingEntity <|.. BaseMovingEntity
BaseMovingEntity *-- Movement
Movement <|.. BaseMovement
BaseMovement <|-- InputMovement
BaseMovement <|-- LinearMovement
BaseMovement <|-- Fixed
BaseMovement <|-- BfsMovement
MovementFactory <|.. MovementFactoryImpl
InputMovement <-- MovementFactory
LinearMovement <-- MovementFactory
RandomMovement <-- MovementFactory
Fixed <-- MovementFactory
<<Interface>> Movement
<<Interface>> MovementFactory
<<Interface>> MovingEntity
class Movement{
update()
getDirection(Vect direction)
setDirection() : Vect
}
class InputMovement{
Vect inputVector
+addDirection(Directions dir)
+removeDirection(Directions dir)
+setNullDirection()
}
class BaseMovement{
Vect dirVector
}
class MovementFactory{
createLinearMovement(Vect direction) : LinearMovement
createBfsMovement() : BfsMovement
createInput() : InputMovement
createFixed() : Fixed
}
class BaseMovingEntity{
<<Abstract>>
Movement movement
+updatePos(long deltatime)
+getDirection() : Vect
+setDirection(Vect)
#appyCollison()
}
Problema: Diverse entità di gioco possono avere diversi movimenti che potrebbero anche cambire a seconda di diversi fattori. Inoltre è importante separare la logica di moviemento dalla logica delle entità stessa per migliorare la modularità e la manutelibilità del codice.
Soluzione: Per affrontare il problema è stata implementata una soluzione che combina il pattern Factory Method e il pattern Strategy.
- Factory Method Pattern: La classe MovementFactory funge da creatore di oggetti di tipo Movement. Questo pattern consente di creare vari tipi di Movement in modo flessibile e scalabile.
- Strategy Pattern: Le varie classi condividono l'interfaccia Movement ma l'implementazione dei metodi varia a seconda del tipo di movement. Inoltre il movimento è inserito come campo all'interno delle varie classi che possono richiamare il vettore direzione risultante del movement e chiamare update per far aggionare al movement questo vettore in base alle logiche della classe.
-
BfsMovementTest: viene testato il funzionamento del movimento dei nemici. Ad ogni chiamata update() il nemico, se non ha ancora raggiunto l'obiettivo, deve spostarsi nella posizione successiva in direzine del target. Per ogni tipologia di nemico viene controllato che si fermi nella corretta posizione, gli Shooter dovranno arrestarsi lontano dal target mentre i Runner devono avvicinarsi il più possibile.
-
EntityFactoryTest: Per ognuna delle entità viene testata la sua corretta creazione.
-
VertexCalculatorTest: Viene testato il corretto calcolo della posizione dei vertici, per le figure geometriche triangolo equilatero, quadrato e rettangolo.
-
GameMapTest: viene testata la corretta creazione della mappa, la quale deve avere almeno un muro, una cassa, un player e degli spawner.
-
EntityManagerTest: viene testato l'EntityManager, quindi il salvataggio delle entità create, la pulizia di quelle non più vive e la condizione di morte di tutti i nemici.
-
HitboxTest: viene testata la corretta creazione delle hitbox a partire da una lista di vertici e il corretto test delle collisioni.
-
MovementTest: in particolare viene testa il corretto funzionamento di inputMovement e di LinearMovement e se lo spostamento avviene come previsto.
Utilizzata per costruire il grafo di GameTile:
Per costruire il grafo solo quando necessario e velocizzare la creazione della mappa:
Usati molto frequentemente, due esempi:
Un esempio:
Adottate frequentemente per la gestione di elenchi di dati:
Usati per convertire i tasti premuti in possibili Value:
Nella classe EngineImpl per gestire conflitti tra main thread e AWT-EventQueue thread:
- Utilizzo di Semaphore per gestire l'accesso alla coda di input:
- Utilizzo di wait e notify per gestire gli stati dell'engine:
Ho preso ispirazione dal codice di Game as a lab per la base dell'engine e della view, in particolare per la scrittura della classe Canvas
.
Queste feaure vengono utilizzate spesso in tutto il codice. Riporto un esempio dove vengono utilizzate tutte quante assieme:
Viene utilizzata l'algoritmo BFS della libreria JGrapht:
Per effettuare i bordi rotondi nei bottoni in Swing, non essendo presenti di default, ho trovato un classe che li implementava:
Utilizzati spesso in tutto il codice:
Utilizzati in particolare in cached game entity:
Molte classi usufruiscono dei generics ad esempio tutte quelle che implementano collision:
Faccio uso di varie classi di JTS (Polygon, Coordinates ...) e di Vector2D di Apache
Questo progetto è nato dall'idea di realizzare un gioco graficamente semplice, ma ben modellato e credo che il nostro lavoro sia riuscito a soddisfare questo obbiettivo. In particolare siamo riusciti molto bene a collaborare tra di noi e ad affrontare i problemi insieme per trovare le migliori soluzioni. Per quanto riguarda la sfera personale, penso di aver fatto il mio meglio ed essermi impegnato per quanto riuscissi e sono soddisfatto di ciò che ho realizzato. Per quanto ci siano delle parti di codice un po' macchinose, come la lettura della mappa da file, o il salvataggio delle statistiche, credo di essere riuscito a sviluppare codice riutilizzabile e facilmente manutenibile.
Sono soddisfatto di quanto abbiamo prodotto: le scelte di design che abbiamo fatto ci hanno permesso di apportare numerose modifiche in corso d'opera, ma queste aggiunte/cambiamenti sono stati facili da implementare e non hanno mai rotto il resto del codice: segno che il design fatto a priori era efficace per i nostri obiettivi. L'unione delle diverse parti di progetto svolte individualmente è stata immediata e senza problemi, potremmo anche implementare nuove feature nel gioco senza modificare il resto del codice: armi, tipi di giocatori e nemici diversi, grafica, etc. Ci sono diversi casi in cui avrei potuto applicare design Pattern che non ho applicato o seguire tecniche di programmazione java più avanzate, ad esempio avrei potuto pensare di seguire il Decorator per creare le sottocategorie di input ed elementi grafici. Avevo come parte del progetto una componente fondamentale per permetterne un funzionamento basilare: la gestione della grafica e degli input. Sono due elementi che portano molta soddisfazione da ragionare e implementare in quanto danno un immediato riscontro visivo; guardando indietro avrei potuto cedere l'implementazione delle armi ad un altro membro del gruppo per bilanciare meglio il carico di lavoro visto che anche solo quelle due hanno impiegato gran parte del tempo che ho dedicato al progetto. Non credo svilupperemo ancora questo gioco, ma è possibile che nel tempo libero io prenda l'engine e la view di questo per sviluppare un gioco diverso.
Durante la realizzazione del progetto, ho avuto l'opportunità di immergermi nel campo dello sviluppo software, affrontando una serie di sfide che hanno arricchito la mia esperienza e le mie conoscenze. La mia principale responsabilità all'interno del gruppo è stata la progettazione e il design delle entità di gioco, concentrandomi in particolare su quelle dotate di vita. Nello svolgimento di questo processo ho avuto modo di applicare le best practice apprese durante il corso, concentrandomi sulla scrittura di codice facilmente estendibile e senza ripetizioni. Un aspetto che ho cercato di mantenere costante nel mio lavoro è stato il rispetto del principio single responsibility, garantendo che le classi e i metodi avessero un ruolo e un compito ben specifico. Il design scelto favorisce l'OCP (principio di apertura/chiusura), il codice è chiuso al cambiamento però se si volessero implementare nuove entità con caratteristiche aggiuntive, sarabbe possibile farlo comodamente. Era la prima volta che lavoravo ad un progetto di tali dimensioni, è stata un'esperienza stimolante, ma anche molto impegnativa. La partenza è stato lo scoglio più grande, inizialmente ho avuto difficoltà a rapportami con git e a gestire i file nei diversi package, però a progetto terminato posso affermare di essere soddisfatto del risultato ottenuto. Abbiamo collaborato tutti assieme nella risoluzione di bug e problemi, ci siamo coordianti riuscendo a consegnare un gioco ben strutturato.
Sono abbastanza soddisfatto del risultato finale del nostro lavoro di gruppo. Abbiamo fatto del nostro meglio nell'organizzare il codice e siamo riusciti a coordinarci bene nella divisione dei compiti. In particolare io mi sono occupato di gestire i movimenti di tutte l'entità, apparte i nemici che sfruttano una bfs, e di scrivere il codice di entità dotate di movimento e di hitboxes in modo di favorire il riuso e di lasciare la possibilità di effettuare numerose modifiche agli elementi di gioco. I punti di forza del mio codice includono la facilità nel aggiungere e rimuovere feature al gioco grazie all'indipendenza del movimento dalle entità e la semplicità nell'implementare le hitbox.
Tuttavia, ho incontrato alcune difficoltà:
- Utilizzo di Git: Soprattutto nelle prime fasi e nei primi commit, ho trovato difficile utilizzare Git in modo efficace.
- Creazione delle GUI: Anche se le GUI sono esteticamente accettabili, ho impiegato troppo tempo nella loro creazione, considerando l'aspetto del progetto.
- Creazione dei test: Ho avuto difficoltà a pensare a un numero sufficiente di test utili. Inoltre, molte delle funzionalità sarebbero state più comode da testare a livello grafico.
- Ricerca delle informazioni sulle librerie non JDK: Trovare esempi di codice per le librerie utilizzate è stato difficile. Ho dovuto fare affidamento principalmente sulla documentazione, che spesso contiene molte classi non riconoscibili, rendendo difficile capire rapidamente se una libreria fosse adatta al nostro progetto o meno.
- Riteniamo sarebbe stato molto più utile al fine del corso e della realizzazione del progetto che i Design Patterns venissero più approfonditi durante le lezioni in aula, in quanto sono stati trattati rapidamente alla fine del corso e ci siamo trovati in difficoltà nell'usarli e riconoscerli.
- Riteniamo che l'attuale forma di prova pratica metta a disposizione troppo poco tempo: in questo momento la prova induce a consegnare un codice scritto male ma funzionante per ottenere il bonus, al posto di valorizzare codice scritto decentemente.
- Potrebbe essere utile dare maggiori esempi e spiegazioni di come strutturare progetti di dimensioni simili a quelli del progetto, similmente a GameAsALab, per avere dei punti di riferimento di codice di qualità.
Comandi di Geometry Fight:
- per muovere il personaggio nelle quattro direzioni usare i tasti WASD;
- per mirare e sparare usare il mouse e il suo tasto sinistro;
- premere il tasto esc o mettere la finestra in secondo piano per mettere in pausa il gioco.
B.0.1 [email protected]
- Laboratorio 06: https://virtuale.unibo.it/mod/forum/discuss.php?d=146511#p208418
- Laboratorio 07: https://virtuale.unibo.it/mod/forum/discuss.php?d=147598#p209242
- Laboratorio 08: https://virtuale.unibo.it/mod/forum/discuss.php?d=148025#p209779
- Laboratorio 09: https://virtuale.unibo.it/mod/forum/discuss.php?d=149231#p211558
- Laboratorio 10: https://virtuale.unibo.it/mod/forum/discuss.php?d=150252#p212661
- Laboratorio 11: https://virtuale.unibo.it/mod/forum/discuss.php?d=151542#p213922
B.0.2 [email protected]
- Laboratorio 06: https://virtuale.unibo.it/mod/forum/discuss.php?d=146511#p208335
- Laboratorio 07: https://virtuale.unibo.it/mod/forum/discuss.php?d=147598#p209241
- Laboratorio 08: https://virtuale.unibo.it/mod/forum/discuss.php?d=148025#p209754
- Laboratorio 09: https://virtuale.unibo.it/mod/forum/discuss.php?d=149231#p211483
- Laboratorio 10: https://virtuale.unibo.it/mod/forum/discuss.php?d=150252#p212726
- Laboratorio 11: https://virtuale.unibo.it/mod/forum/discuss.php?d=151542#p213929
B.0.3 [email protected]
- Laboratorio 07: https://virtuale.unibo.it/mod/forum/discuss.php?d=147598#p209331
- Laboratorio 08: https://virtuale.unibo.it/mod/forum/discuss.php?d=148025#p210404
- Laboratorio 09: https://virtuale.unibo.it/mod/forum/discuss.php?d=149231#p211533
- Laboratorio 10: https://virtuale.unibo.it/mod/forum/discuss.php?d=150252#p212814
B.0.4 [email protected]
- Laboratorio 06: https://virtuale.unibo.it/mod/forum/discuss.php?d=146511#p208457
- Laboratorio 07: https://virtuale.unibo.it/mod/forum/discuss.php?d=147598#p209382
- Laboratorio 08: https://virtuale.unibo.it/mod/forum/discuss.php?d=148025#p210337
- Laboratorio 09: https://virtuale.unibo.it/mod/forum/discuss.php?d=149231#p211551
- Laboratorio 10: https://virtuale.unibo.it/mod/forum/discuss.php?d=150252#p212799