9ifr PDF
9ifr PDF
Creative Commons License: Usted es libre de copiar, distribuir y comunicar públicamente la obra, bajo
las condiciones siguientes: 1. Reconocimiento. Debe reconocer los créditos de la obra de la manera
especificada por el autor o el licenciador. 2. No comercial. No puede utilizar esta obra para fines
comerciales. 3. Sin obras derivadas. No se puede alterar, transformar o generar una obra derivada a
partir de esta obra. Más información en: http://creativecommons.org/licenses/by-nc-nd/3.0/
Prefacio
1. Arquitectura del Motor, donde se estudian los aspectos esenciales del diseño
de un motor de videojuegos, así como las técnicas básicas de programación
y patrones de diseño. En este bloque también se estudian los conceptos más
relevantes del lenguaje de programación C++.
2. Programación Gráfica, donde se presta especial atención a los algoritmos y
técnicas de representación gráfica, junto con las optimizaciones en sistemas de
despliegue interactivo.
3. Técnicas Avanzadas, donde se recogen ciertos aspectos avanzados, como es-
tructuras de datos específicas, técnicas de validación y pruebas o simulación
física. Así mismo, en este bloque se profundiza en el lenguaje C++.
4. Desarrollo de Componentes, donde, finalmente, se detallan ciertos componen-
tes específicos del motor, como la Inteligencia Artificial, Networking, Sonido y
Multimedia o técnicas avanzadas de Interacción.
Sobre este libro
Este libro que tienes en tus manos es una ampliación y revisión de los apuntes del
Curso de Experto en Desarrollo de Videojuegos, impartido en la Escuela Superior de
Informática de Ciudad Real de la Universidad de Castilla-La Mancha. Puedes obtener
más información sobre el curso, así como los resultados de los trabajos creados por
los alumnos, en la web del mismo: http://www.cedv.es. La versión electrónica de este
libro puede descargarse desde la web anterior. El libro «físico» puede adquirirse desde
la página web de la editorial online Edlibrix en http://www.shoplibrix.com.
Requisitos previos
Este libro tiene un público objetivo con un perfil principalmente técnico. Al igual
que el curso del que surgió, está orientado a la capacitación de profesionales de la
programación de videojuegos. De esta forma, este libro no está orientado para un
público de perfil artístico (modeladores, animadores, músicos, etc.) en el ámbito de
los videojuegos.
Se asume que el lector es capaz de desarrollar programas de nivel medio en C
y C++. Aunque se describen algunos aspectos clave de C++ a modo de resumen, es
recomendable refrescar los conceptos básicos con alguno de los libros recogidos en
la bibliografía. De igual modo, se asume que el lector tiene conocimientos de estruc-
turas de datos y algoritmia. El libro está orientado principalmente para titulados o
estudiantes de últimos cursos de Ingeniería en Informática.
Agradecimientos
Los autores del libro quieren agradecer, en primer lugar, a los alumnos de las tres
primeras ediciones del Curso de Experto en Desarrollo de Videojuegos por su partici-
pación en el mismo y el excelente ambiente en las clases, las cuestiones planteadas y
la pasión demostrada en el desarrollo de todos los trabajos.
De igual modo, se quiere reflejar el agradecimiento especial al personal de admi-
nistración y servicios de la Escuela Superior de Informática, por su soporte, predis-
posición y ayuda en todos los caprichosos requisitos que planteábamos a lo largo del
curso.
Por otra parte, este agradecimiento también se hace extensivo a la Escuela de In-
formatica de Ciudad Real y al Departamento de Tecnologías y Sistema de Información
de la Universidad de Castilla-La Mancha.
Finalmente, los autores desean agradecer su participación a los colaboradores de
las tres primeras ediciones: Indra Software Labs, la asociación de desarrolladores de
videojuegos Stratos, Libro Virtual, Devilish Games, Dolores Entertainment, From the
Bench, Iberlynx, KitMaker, Playspace, Totemcat/Materia Works y ZuinqStudio.
Autores
1. Introducción 3
1.1. El desarrollo de videojuegos . . . . . . . . . . . . . . . . . . . . . . 3
1.1.1. La industria del videojuego. Presente y futuro . . . . . . . . . 3
1.1.2. Estructura típica de un equipo de desarrollo . . . . . . . . . . 5
1.1.3. El concepto de juego . . . . . . . . . . . . . . . . . . . . . . 7
1.1.4. Motor de juego . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.1.5. Géneros de juegos . . . . . . . . . . . . . . . . . . . . . . . 10
1.2. Arquitectura del motor. Visión general . . . . . . . . . . . . . . . . . 15
1.2.1. Hardware, drivers y sistema operativo . . . . . . . . . . . . . 15
1.2.2. SDKs y middlewares . . . . . . . . . . . . . . . . . . . . . . 16
1.2.3. Capa independiente de la plataforma . . . . . . . . . . . . . . 17
1.2.4. Subsistemas principales . . . . . . . . . . . . . . . . . . . . 18
1.2.5. Gestor de recursos . . . . . . . . . . . . . . . . . . . . . . . 18
1.2.6. Motor de rendering . . . . . . . . . . . . . . . . . . . . . . . 19
1.2.7. Herramientas de depuración . . . . . . . . . . . . . . . . . . 22
1.2.8. Motor de física . . . . . . . . . . . . . . . . . . . . . . . . . 22
1.2.9. Interfaces de usuario . . . . . . . . . . . . . . . . . . . . . . 23
1.2.10. Networking y multijugador . . . . . . . . . . . . . . . . . . . 23
1.2.11. Subsistema de juego . . . . . . . . . . . . . . . . . . . . . . 24
1.2.12. Audio . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
1.2.13. Subsistemas específicos de juego . . . . . . . . . . . . . . . . 26
2. Herramientas de Desarrollo 27
I
[II] ÍNDICE GENERAL
2.1. Introducción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
2.2. Compilación, enlazado y depuración . . . . . . . . . . . . . . . . . . 28
2.2.1. Conceptos básicos . . . . . . . . . . . . . . . . . . . . . . . 28
2.2.2. Compilando con GCC . . . . . . . . . . . . . . . . . . . . . 31
2.2.3. ¿Cómo funciona GCC? . . . . . . . . . . . . . . . . . . . . . 31
2.2.4. Ejemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
2.2.5. Otras herramientas . . . . . . . . . . . . . . . . . . . . . . . 39
2.2.6. Depurando con GDB . . . . . . . . . . . . . . . . . . . . . . 39
2.2.7. Construcción automática con GNU Make . . . . . . . . . . . 45
2.3. Gestión de proyectos y documentación . . . . . . . . . . . . . . . . . 50
2.3.1. Sistemas de control de versiones . . . . . . . . . . . . . . . . 50
2.3.2. Documentación . . . . . . . . . . . . . . . . . . . . . . . . . 59
2.3.3. Forjas de desarrollo . . . . . . . . . . . . . . . . . . . . . . . 62
Anexos 1079
XXI
[XXII] ACRÓNIMOS
DD Debian Developer
DEB Deep Estimation Buffer
DEHS Debian External Health Status
DFSG Debian Free Software Guidelines
DIP Dependency Inversion Principle
DLL Dynamic Link Library
DOD Diamond Of Death
DOM Document Object Model
DTD Documento Técnico de Diseño
DVB Digital Video Broadcasting
DVD Digital Video Disc
E/S Entrada/Salida
EASTL Electronic Arts Standard Template Library
EA Electronic Arts
ELF Executable and Linkable Format
FIFO First In, First Out
FLAC Free Lossless Audio Codec
FPS First Person Shooter
FSM Finite State Machine
GCC GNU Compiler Collection
GDB GNU Debugger
GDD Game Design Document
GIMP GNU Image Manipulation Program
GLUT OpenGL Utility Toolkit
GLU OpenGL Utility
GNOME GNU Object Model Environment
GNU GNU is Not Unix
GPL General Public License
GPS Global Positioning System
GPU Graphic Processing Unit
GPV grafos de puntos visibles
GTK GIMP ToolKit
GUI Graphical User Interface
GUP Game Unified Process
HDTV High Definition Television
HFSM Hierarchical Finite State Machine
HOM Hierarchical Occlusion Maps
HSV Hue, Saturation, Value
HTML HyperText Markup Language
I/O Input/Output
IANA Internet Assigned Numbers Authority
IA Inteligencia Artificial
IBM International Business Machines
IDL Interface Definition Language
[XXIII]
Introducción
1
David Vallejo Fernández
A
ctualmente, la industria del videojuego goza de una muy buena salud a nivel
mundial, rivalizando en presupuesto con las industrias cinematográfica y mu-
sical. En este capítulo se discute, desde una perspectiva general, el desarrollo
de videojuegos, haciendo especial hincapié en su evolución y en los distintos elemen-
tos involucrados en este complejo proceso de desarrollo. En la segunda parte del capí-
tulo se introduce el concepto de arquitectura del motor, como eje fundamental para
el diseño y desarrollo de videojuegos comerciales.
El primer videojuego Lejos han quedado los días desde el desarrollo de los primeros videojuegos, ca-
racterizados principalmente por su simplicidad y por el hecho de estar desarrollados
El videojuego Pong se considera completamente sobre hardware. Debido a los distintos avances en el campo de la in-
como unos de los primeros video- formática, no sólo a nivel de desarrollo software y capacidad hardware sino también
juegos de la historia. Desarrollado
por Atari en 1975, el juego iba in- en la aplicación de métodos, técnicas y algoritmos, la industria del videojuego ha evo-
cluido en la consola Atari Pong. lucionado hasta llegar a cotas inimaginables, tanto a nivel de jugabilidad como de
Se calcula que se vendieron unas calidad gráfica, tan sólo hace unos años.
50.000 unidades.
3
[4] CAPÍTULO 1. INTRODUCCIÓN
Tiempo real El desarrollo de videojuegos comerciales es un proceso complejo debido a los dis-
tintos requisitos que ha de satisfacer y a la integración de distintas disciplinas que
En el ámbito del desarrollo de vi- intervienen en dicho proceso. Desde un punto de vista general, un videojuego es una
deojuegos, el concepto de tiempo aplicación gráfica en tiempo real en la que existe una interacción explícita mediante
real es muy importante para dotar
de realismo a los juegos, pero no el usuario y el propio videojuego. En este contexto, el concepto de tiempo real se refie-
es tan estricto como el concepto de re a la necesidad de generar una determinada tasa de frames o imágenes por segundo,
tiempo real manejado en los siste- típicamente 30 ó 60, para que el usuario tenga una sensación continua de realidad.
mas críticos. Por otra parte, la interacción se refiere a la forma de comunicación existente entre el
usuario y el videojuego. Normalmente, esta interacción se realiza mediante joysticks o
mandos, pero también es posible llevarla a cabo con otros dispositivos como por ejem-
plo teclados, ratones, cascos o incluso mediante el propio cuerpo a través de técnicas
de visión por computador o de interacción táctil.
A continuación se describe la estructura típica de un equipo de desarrollo aten-
diendo a los distintos roles que juegan los componentes de dicho equipo [42]. En
muchos casos, y en función del número de componentes del equipo, hay personas
especializadas en diversas disciplinas de manera simultánea.
Los ingenieros son los responsables de diseñar e implementar el software que
permite la ejecución del juego, así como las herramientas que dan soporte a dicha
ejecución. Normalmente, los ingenieros se suelen clasificar en dos grandes grupos:
Los programadores del núcleo del juego, es decir, las personas responsables
de desarrollar tanto el motor de juego como el juego propiamente dicho.
Los programadores de herramientas, es decir, las personas responsables de
desarrollar las herramientas que permiten que el resto del equipo de desarrollo
pueda trabajar de manera eficiente.
Productor ejecutivo
Programador Networking
Equipo artístico
Herramientas Física
Conceptual Modelado Artista
técnico Inteligencia Art. Motor
Animación Texturas
Interfaces Audio
Al igual que suele ocurrir con los ingenieros, existe el rol de artista senior cuyas
responsabilidades también incluyen la supervisión de los numerosos aspectos vincu-
lados al componente artístico.
Los diseñadores de juego son los responsables de diseñar el contenido del juego,
Scripting e IA destacando la evolución del mismo desde el principio hasta el final, la secuencia de
El uso de lenguajes de alto nivel capítulos, las reglas del juego, los objetivos principales y secundarios, etc. Evidente-
es bastante común en el desarrollo mente, todos los aspectos de diseño están estrechamente ligados al propio género del
de videojuegos y permite diferen- mismo. Por ejemplo, en un juego de conducción es tarea de los diseñadores definir el
ciar claramente la lógica de la apli- comportamiento de los coches adversarios ante, por ejemplo, el adelantamiento de un
cación y la propia implementación.
Una parte significativa de las desa- rival.
rrolladoras utiliza su propio lengua- Los diseñadores suelen trabajar directamente con los ingenieros para afrontar di-
je de scripting, aunque existen len-
guajes ampliamente utilizados, co- versos retos, como por ejemplo el comportamiento de los enemigos en una aventura.
mo son Lua o Python. De hecho, es bastante común que los propios diseñadores programen, junto con los in-
genieros, dichos aspectos haciendo uso de lenguajes de scripting de alto nivel, como
por ejemplo Lua4 o Python5 .
Como ocurre con las otras disciplinas previamente comentadas, en algunos estu-
dios los diseñadores de juego también juegan roles de gestión y supervisión técnica.
Finalmente, en el desarrollo de videojuegos también están presentes roles vincu-
lados a la producción, especialmente en estudios de mayor capacidad, asociados a la
planificación del proyecto y a la gestión de recursos humanos. En algunas ocasiones,
los productores también asumen roles relacionados con el diseño del juego. Así mis-
mo, los responsables de marketing, de administración y de soporte juegan un papel
relevante. También resulta importante resaltar la figura de publicador como entidad
responsable del marketing y distribución del videojuego desarrollado por un determi-
nado estudio. Mientras algunos estudios tienen contratos permanentes con un deter-
minado publicador, otros prefieren mantener una relación temporal y asociarse con el
publicador que le ofrezca mejores condiciones para gestionar el lanzamiento de un
título.
En otras palabras, los juegos son aplicaciones interactivas que están marcadas por
el tiempo, es decir, cada uno de los ciclos de ejecución tiene un deadline que ha de
cumplirse para no perder realismo.
Aunque el componente gráfico representa gran parte de la complejidad compu-
tacional de los videojuegos, no es el único. En cada ciclo de ejecución, el videojuego
ha de tener en cuenta la evolución del mundo en el que se desarrolla el mismo. Dicha
evolución dependerá del estado de dicho mundo en un momento determinado y de có-
mo las distintas entidades dinámicas interactúan con él. Obviamente, recrear el mundo
C1
1.1. El desarrollo de videojuegos [9]
real con un nivel de exactitud elevado no resulta manejable ni práctico, por lo que nor-
malmente dicho mundo se aproxima y se simplifica, utilizando modelos matemáticos
para tratar con su complejidad. En este contexto, destaca por ejemplo la simulación
física de los propios elementos que forman parte del mundo.
Por otra parte, un juego también está ligado al comportamiento del personaje prin-
cipal y del resto de entidades que existen dentro del mundo virtual. En el ámbito
académico, estas entidades se suelen definir como agentes (agents) y se encuadran
dentro de la denominada simulación basada en agentes [64]. Básicamente, este tipo
de aproximaciones tiene como objetivo dotar a los NPC con cierta inteligencia pa-
ra incrementar el grado de realismo de un juego estableciendo, incluso, mecanismos
de cooperación y coordinación entre los mismos. Respecto al personaje principal, un
videojuego ha de contemplar las distintas acciones realizadas por el mismo, consi-
derando la posibilidad de decisiones impredecibles a priori y las consecuencias que
podrían desencadenar.
Figura 1.3: El motor de juego re- En resumen, y desde un punto de vista general, el desarrollo de un juego implica
presenta el núcleo de un videojuego considerar un gran número de factores que, inevitablemente, incrementan la comple-
y determina el comportamiento de jidad del mismo y, al mismo tiempo, garantizar una tasa de fps adecuada para que la
los distintos módulos que lo com- inmersión del usuario no se vea afectada.
ponen.
Obviamente, la separación entre motor de juego y juego nunca es total y, por una
circunstancia u otra, siempre existen dependencias directas que no permiten la reusa-
bilidad completa del motor para crear otro juego. La dependencia más evidente es el
genero al que está vinculado el motor de juego. Por ejemplo, un motor de juegos dise-
ñado para construir juegos de acción en primera persona, conocidos tradicionalmente
como shooters o shoot’em all, será difícilmente reutilizable para desarrollar un juego
de conducción.
Una forma posible para diferenciar un motor de juego y el software que representa
a un juego está asociada al concepto de arquitectura dirigida por datos (data-driven
architecture). Básicamente, cuando un juego contiene parte de su lógica o funciona-
miento en el propio código (hard-coded logic), entonces no resulta práctico reutilizar-
la para otro juego, ya que implicaría modificar el código fuente sustancialmente. Sin
embargo, si dicha lógica o comportamiento no está definido a nivel de código, sino
por ejemplo mediante una serie de reglas definidas a través de un lenguaje de script,
entonces la reutilización sí es posible y, por lo tanto, beneficiosa, ya que optimiza el
tiempo de desarrollo.
Como conclusión final, resulta relevante destacar la evolución relativa a la genera- Game engine tuning
lidad de los motores de juego, ya que poco a poco están haciendo posible su utilización
para diversos tipos de juegos. Sin embargo, el compromiso entre generalidad y optima- Los motores de juegos se suelen
adaptar para cubrir las necesidades
lidad aún está presente. En otras palabras, a la hora de desarrollar un juego utilizando específicas de un título y para obte-
un determinado motor es bastante común personalizar dicho motor para adaptarlo a ner un mejor rendimiento.
las necesidades concretas del juego a desarrollar.
Figura 1.5: Captura de pantalla del juego Tremulous R , licenciado bajo GPL y desarrollado sobre el motor
de Quake III.
Otro género importante está representado por los juegos de lucha, en los que,
normalmente, dos jugadores compiten para ganar un determinado número de com-
bates minando la vida o stamina del jugador contrario. Ejemplos representativos de
juegos de lucha son Virtua Fighter, Street Fighter, Tekken, o Soul Calibur, entre otros.
Actualmente, los juegos de lucha se desarrollan normalmente en escenarios tridimen-
sionales donde los luchadores tienen una gran libertad de movimiento. Sin embargo,
últimamente se han desarrollado diversos juegos en los que tanto el escenario como
los personajes son en 3D, pero donde el movimiento de los mismos está limitado a dos
dimensiones, enfoque comúnmente conocido como juegos de lucha de scroll lateral.
Debido a que en los juegos de lucha la acción se centra generalmente en dos per-
sonajes, éstos han de tener una gran calidad gráfica y han de contar con una gran
variedad de movimientos y animaciones para dotar al juego del mayor realismo posi-
ble. Así mismo, el escenario de lucha suele estar bastante acotado y, por lo tanto, es
posible simplificar su tratamiento y, en general, no es necesario utilizar técnicas de op-
timización como las comentadas en el género de los FPS. Por otra parte, el tratamiento
de sonido no resulta tan complejo como lo puede ser en otros géneros de acción.
Los juegos del género de la lucha han de prestar atención a la detección y gestión
de colisiones entre los propios luchadores, o entre las armas que utilicen, para dar una
sensación de mayor realismo. Además, el módulo responsable del tratamiento de la
entrada al usuario ha de ser lo suficientemente sofisticado para gestionar de manera
adecuada las distintas combinaciones de botones necesarias para realizar complejos
movimientos. Por ejemplo, juegos como Street Fighter IV incorporan un sistema de
timing entre los distintos movimientos de un combo. El objetivo perseguido consiste
Simuladores F1 en que dominar completamente a un personaje no sea una tarea sencilla y requiera que
Los simuladores de juegos de con- el usuario de videojuegos dedique tiempo al entrenaiento del mismo.
ducción no sólo se utilizan para Los juegos de lucha, en general, han estado ligados a la evolución de técnicas
el entretenimiento doméstico sino
también para que, por ejemplo, los complejas de síntesis de imagen aplicadas sobre los propios personajes con el objetivo
pilotos de Fórmula-1 conozcan to- de mejorar al máximo su calidad y, de este modo, incrementar su realismo. Un ejemplo
dos los entresijos de los circuitos y representativo es el uso de shaders [76] sobre la armadura o la propia piel de los
puedan conocerlos al detalle antes
de embarcarse en los entrenamien-
personajes que permitan implementar técnicas como el bump mapping [6], planteada
tos reales. para dotar a estos elementos de un aspecto más rugoso.
[14] CAPÍTULO 1. INTRODUCCIÓN
Al igual que ocurre en los juegos de estrategia, los MMOG suelen utilizar persona-
jes virtuales en baja resolución para permitir la aparición de un gran número de ellos
en pantalla de manera simultánea.
Además de los distintos géneros mencionados en esta sección, existen algunos
más como por ejemplo los juegos deportivos, los juegos de rol o RPG (Role-Playing
Games) o los juegos de puzzles.
Antes de pasar a la siguiente sección en la que se discutirá la arquitectura general
de un motor de juego, resulta interesante destacar la existencia de algunas herramien-
tas libres que se pueden utilizar para la construcción de un motor de juegos. Una de
las más populares, y que se utilizará en el presente curso, es OGRE 3D9 . Básicamente,
OGRE es un motor de renderizado 3D bien estructurado y con una curva de aprendi-
zaje adecuada. Aunque OGRE no se puede definir como un motor de juegos completo,
sí que proporciona un gran número de módulos que permiten integrar funcionalidades
no triviales, como iluminación avanzada o sistemas de animación de caracteres.
La arquitectura Cell La capa relativa al hardware está vinculada a la plataforma en la que se ejecutará
el motor de juego. Por ejemplo, un tipo de plataforma específica podría ser una consola
En arquitecturas más novedosas, de juegos de sobremesa. Muchos de los principios de diseño y desarrollo son comu-
como por ejemplo la arquitectura nes a cualquier videojuego, de manera independiente a la plataforma de despliegue
Cell usada en Playstation 3 y desa-
rrollada por Sony, Toshiba e IBM, final. Sin embargo, en la práctica los desarrolladores de videojuegos siempre llevan
las optimizaciones aplicadas suelen a cabo optimizaciones en el motor de juegos para mejorar la eficiencia del mismo,
ser más dependientes de la platafor- considerando aquellas cuestiones que son específicas de una determinada plataforma.
ma final.
La capa de drivers soporta aquellos componentes software de bajo nivel que per-
miten la correcta gestión de determinados dispositivos, como por ejemplo las tarjetas
de aceleración gráfica o las tarjetas de sonido.
La capa del sistema operativo representa la capa de comunicación entre los pro-
cesos que se ejecutan en el mismo y los recursos hardware asociados a la plataforma
en cuestión. Tradicionalmente, en el mundo de los videojuegos los sistemas opera-
tivos se compilan con el propio juego para producir un ejecutable. Sin embargo, las
9 http://www.ogre3d.org/
[16] CAPÍTULO 1. INTRODUCCIÓN
Subsistema
Networking Audio
de juego
Gestor de recursos
Subsistemas principales
Sistema operativo
Drivers
Hardware
Figura 1.9: Visión conceptual de la arquitectura general de un motor de juegos. Esquema adaptado de la
arquitectura propuesta en [42].
Abstracción funcional Gran parte de los juegos se desarrollan teniendo en cuenta su potencial lanzamien-
to en diversas plataformas. Por ejemplo, un título se puede desarrollar para diversas
Aunque en teoría las herramien- consolas de sobremesa y para PC al mismo tiempo. En este contexto, es bastante co-
tas multiplataforma deberían abs-
traer de los aspectos subyacentes
mún encontrar una capa software que aisle al resto de capas superiores de cualquier
a las mismas, como por ejemplo aspecto que sea dependiente de la plataforma. Dicha capa se suele denominar capa
el sistema operativo, en la práctica independiente de la plataforma.
suele ser necesario realizar algunos
ajustos en función de la plataforma Aunque sería bastante lógico suponer que la capa inmediatamente inferior, es de-
existente en capas de nivel inferior. cir, la capa de SDKs y middleware, ya posibilita la independencia respecto a las pla-
taformas subyacentes debido al uso de módulos estandarizados, como por ejemplo
bibliotecas asociadas a C/C++, la realidad es que existen diferencias incluso en bi-
bliotecas estandarizadas para distintas plataformas.
Algunos ejemplos representativos de módulos incluidos en esta capa son las bi-
bliotecas de manejo de hijos o los wrappers o envolturas sobre alguno de los módulos
de la capa superior, como el módulo de detección de colisiones o el responsable de la
parte gráfica.
10 http://www.sgi.com/tech/stl/
11 http://http://www.opengl.org/
12 http://www.havok.com
13 http://www.ode.org
[18] CAPÍTULO 1. INTRODUCCIÓN
Recurso Recurso
Mundo Etc
esqueleto colisión
Gestor de recursos
Figura 1.10: Visión conceptual del gestor de recursos y sus entidades asociadas. Esquema adaptado de la
arquitectura propuesta en [42].
Ogre::ScriptLoader
ResourceAlloc Ogre::TextureManager
Ogre::SkeletonManager
Ogre::MeshManager
Ogre::ResourceManager Ogre::MaterialManager
Ogre::GPUProgramManager
Ogre::FontManager
Ogre::CompositeManager
Figura 1.11: Diagrama de clases asociado al gestor de recursos de Ogre 3D, representado por la clase
Ogre::ResourceManager.
Front end
Efectos visuales
Interfaz con el
dispositivo gráfico
Motor de rendering
Figura 1.12: Visión conceptual de la arquitectura general de un motor de rendering. Esquema simplificado
de la arquitectura discutida en [42].
Por otra parte, dicha capa también gestiona el estado del hardware gráfico y los
shaders asociados. Básicamente, cada primitiva recibida por esta capa tiene asociado
un material y se ve afectada por diversas fuentes de luz. Así mismo, el material descri-
be la textura o texturas utilizadas por la primitiva y otras cuestiones como por ejemplo
qué pixel y vertex shaders se utilizarán para renderizarla.
La capa superior a la de renderizado de bajo nivel se denomina scene graph/-
culling y optimizaciones y, desde un punto de vista general, es la responsable de
seleccionar qué parte o partes de la escena se enviarán a la capa de rendering. Esta se-
lección, u optimización, permite incrementar el rendimiento del motor de rendering,
debido a que se limita el número de primitivas geométricas enviadas a la capa de nivel
inferior.
Aunque en la capa de rendering sólo se dibujan las primitivas que están dentro del
campo de visión de la cámara, es decir, dentro del viewport, es posible aplicar más
optimizaciones que simplifiquen la complejidad de la escena a renderizar, obviando
aquellas partes de la misma que no son visibles desde la cámara. Este tipo de optimi-
zaciones son críticas en juegos que tenga una complejidad significativa con el objetivo
de obtener tasas de frames por segundo aceptables.
Una de las optimizaciones típicas consiste en hacer uso de estructuras de datos de
subdivisión espacial para hacer más eficiente el renderizado, gracias a que es posible
determinar de una manera rápida el conjunto de objetos potencialmente visibles. Di-
chas estructuras de datos suelen ser árboles, aunque también es posible utilizar otras
alternativas. Tradicionalmente, las subdivisiones espaciales se conocen como scene
graph (grafo de escena), aunque en realidad representan un caso particular de estruc-
tura de datos.
Por otra parte, en esta capa también es común integrar métodos de culling, como
por ejemplo aquellos basados en utilizar información relevante de las oclusiones para
determinar qué objetos están siendo solapados por otros, evitando que los primeros se
tengan que enviar a la capa de rendering y optimizando así este proceso.
Idealmente, esta capa debería ser independiente de la capa de renderizado, permi-
tiendo así aplicar distintas optimizaciones y abstrayéndose de la funcionalidad rela-
tiva al dibujado de primitivas. Un ejemplo representativo de esta independencia está
representado por OGRE (Object-Oriented Graphics Rendering Engine) y el uso de la
filosofía plug & play, de manera que el desarrollador puede elegir distintos diseños de
grafos de escenas ya implementados y utilizarlos en su desarrollo.
Filosofía Plug & Play Sobre la capa relativa a las optimizaciones se sitúa la capa de efectos visuales, la
cual proporciona soporte a distintos efectos que, posteriormente, se puedan integrar en
Esta filosofía se basa en hacer uso los juegos desarrollados haciendo uso del motor. Ejemplos representativos de módulos
de un componente funcional, hard-
ware o software, sin necesidad de
que se incluyen en esta capa son aquéllos responsables de gestionar los sistemas de
configurar ni de modificar el fun- partículos (humo, agua, etc), los mapeados de entorno o las sombras dinámicas.
cionamiento de otros componentes
asociados al primero. Finalmente, la capa de front-end suele estar vinculada a funcionalidad relativa
a la superposición de contenido 2D sobre el escenario 3D. Por ejemplo, es bastante
común utilizar algún tipo de módulo que permita visualizar el menú de un juego o la
interfaz gráfica que permite conocer el estado del personaje principal del videojuego
(inventario, armas, herramientas, etc). En esta capa también se incluyen componentes
para reproducir vídeos previamente grabados y para integrar secuencias cinemáticas,
a veces interactivas, en el propio videojuego. Este último componente se conoce como
IGC (In-Game Cinematics) system.
[22] CAPÍTULO 1. INTRODUCCIÓN
Por otra parte, algunos juegos incluyen sistemas realistas o semi-realistas de simu-
lación dinámica. En el ámbito de la industria del videojuego, estos sistemas se suelen
denominar sistema de física y están directamente ligados al sistema de gestión de
colisiones.
Actualmente, la mayoría de compañías utilizan motores de colisión/física desarro-
llados por terceras partes, integrando estos kits de desarrollo en el propio motor. Los
más conocidos en el ámbito comercial son Havok, el cual representa el estándar de
facto en la industria debido a su potencia y rendimiento, y PhysX, desarrollado por
NVIDIA e integrado en motores como por ejemplo el Unreal Engine 3.
En el ámbito del open source, uno de los más utilizados es ODE. Sin embargo,
en este curso se hará uso del motor de simulación física Bullet14 , el cual se utiliza
actualmente en proyectos tan ambiciosos como la suite 3D Blender.
Sistema de scripting
Subsistema de juego
Figura 1.13: Visión conceptual de la arquitectura general del subsistema de juego. Esquema simplificado
de la arquitectura discutida en [42].
Este subsistema sirve también como capa de aislamiento entre las capas de más
bajo nivel, como por ejemplo la de rendering, y el propio funcionamiento del juego. Diseñando juegos
Es decir, uno de los principales objetivos de diseño que se persiguen consiste en inde-
pendizar la lógica del juego de la implementación subyacente. Por ello, en esta capa es Los diseñadores de los niveles de un
juego, e incluso del comportamien-
bastante común encontrar algún tipo de sistema de scripting o lenguaje de alto nivel to de los personajes y los NPCs,
para definir, por ejemplo, el comportamiento de los personajes que participan en el suelen dominar perfectamente los
juego. lenguajes de script, ya que son su
principal herramienta para llevar a
cabo su tarea.
C1
1.2. Arquitectura del motor. Visión general [25]
El modelo de objetos del juego está intimamente ligado al modelo de objetos soft-
ware y se puede entender como el conjunto de propiedades del lenguaje, políticas y
convenciones utilizadas para implementar código utilizando una filosofía de orienta-
ción a objetos. Así mismo, este modelo está vinculado a cuestiones como el lenguaje
de programación empleado o a la adopción de una política basada en el uso de patrones
de diseño, entre otras.
En la capa de subsistema de juego se integra el sistema de eventos, cuya principal
responsabilidad es la de dar soporte a la comunicación entre objetos, independiente-
mente de su naturaleza y tipo. Un enfoque típico en el mundo de los videojuegos con-
siste en utilizar una arquitectura dirigida por eventos, en la que la principal entidad es
el evento. Dicho evento consiste en una estructura de datos que contiene información
relevante de manera que la comunicación está precisamente guiada por el contenido
del evento, y no por el emisor o el receptor del mismo. Los objetos suelen implementar
manejadores de eventos (event handlers) para tratarlos y actuar en consecuencia.
Por otra parte, el sistema de scripting permite modelar fácilmente la lógica del
juego, como por ejemplo el comportamiento de los enemigos o NPCs, sin necesidad
de volver a compilar para comprobar si dicho comportamiento es correcto o no. En
algunos casos, los motores de juego pueden seguir en funcionamiento al mismo tiempo
que se carga un nuevo script.
Finalmente, en la capa del subsistema de juego es posible encontrar algún módulo
que proporcione funcionalidad añadida respecto al tratamiento de la IA, normalmente
de los NPCs. Este tipo de módulos, cuya funcionalidad se suele incluir en la propia
capa de software específica del juego en lugar de integrarla en el propio motor, son
cada vez más populares y permiten asignar comportamientos preestablecidos sin nece-
sidad de programarlos. En este contexto, la simulación basada en agentes [102] cobra
especial relevancia.
Este tipo de módulos pueden incluir aspectos relativos a problemas clásicos de la
IA, como por ejemplo la búsqueda de caminos óptimos entre dos puntos, conocida
como pathfinding, y típicamente vinculada al uso de algoritmos A* [79]. Así mismo,
también es posible hacer uso de información privilegiada para optimizar ciertas tareas,
I’m all ears!
como por ejemplo la localización de entidades de interés para agilizar el cálculo de
El apartado sonoro de un juego es aspectos como la detección de colisiones.
especialmente importante para que
el usuario se sienta inmerso en el
mismo y es crítico para acompañar
de manera adecuada el desarrollo de
dicho juego.
[26] CAPÍTULO 1. INTRODUCCIÓN
1.2.12. Audio
Tradicionalmente, el mundo del desarrollo de videojuegos siempre ha prestado
más atención al componente gráfico. Sin embargo, el apartado sonoro también tiene
una gran importancia para conseguir una inmersión total del usuario en el juego. Por
ello, el motor de audio ha ido cobrando más y más relevancia.
Asimismo, la aparición de nuevos formatos de audio de alta definición y la popu-
laridad de los sistemas de cine en casa han contribuido a esta evolución en el cada vez
más relevante apartado sonoro.
Actualmente, al igual que ocurre con otros componentes de la arquitectura del mo-
tor de juego, es bastante común encontrar desarrollos listos para utilizarse e integrarse
en el motor de juego, los cuales han sido realizados por compañías externas a la del
propio motor. No obstante, el apartado sonoro también requiere modificaciones que
son específicas para el juego en cuestión, con el objetivo de obtener un alto de grado
de fidelidad y garantizar una buena experiencia desde el punto de visto auditivo.
A
ctualmente, existen un gran número de aplicaciones y herramientas que permi-
ten a los desarrolladores de videojuegos, y de aplicaciones en general, aumen-
tar su productividad a la hora de construir software, gestionar los proyectos y
recursos, así como automatizar procesos de construcción.
En este capítulo, se pone de manifiesto la importancia de la gestión en un proyec-
to software y se muestran algunas de las herramientas de desarrollo más conocidas
en sistemas GNU/Linux. La elección de este tipo de sistema no es casual. Por un la-
do, se trata de Software Libre, lo que permite a desarrolladores estudiar, aprender y
entender lo que hace el código que se ejecuta. Por otro lado, probablemente sea el
mejor sistema operativo para construir y desarrollar aplicaciones debido al gran nú-
mero de herramientas que proporciona. Es un sistema hecho por programadores para
programadores.
2.1. Introducción
En la construcción de software no trivial, las herramientas de gestión de proyec-
tos y de desarrollo facilitan la labor de las personas que lo construyen. Conforme el
software se va haciendo más complejo y se espera más funcionalidad de él, se hace
necesario el uso de herramientas que permitan automatizar los procesos del desarrollo,
así como la gestión del proyecto y su documentación.
Además, dependiendo del contexto, es posible que existan otros integrantes del
proyecto que no tengan formación técnica y que necesiten realizar labores sobre el
producto como traducciones, pruebas, diseño gráfico, etc.
27
[28] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
C2
Código fuente, código objeto y código ejecutable
Compilador
Enlazador
Un programa puede estar compuesto por varios módulos, lo cual permite que un
proyecto pueda ser más mantenible y manejable. Los módulos pueden tener indepen-
dencias entre sí y la comprobación y resolución de estas dependencias corren a cargo
del enlazador. El enlazador toma como entrada el código objeto.
Bibliotecas
Una de las principales ventajas del software es la reutilización del código. Normal-
mente, los problemas pueden resolverse utilizando código ya escrito anteriormente y
la reutilización del mismo se vuelve un aspecto clave para el tiempo de desarrollo del
producto. Las bibliotecas ofrecen una determinada funcionalidad ya implementada
para que sea utilizada por programas. Las bibliotecas se incorporan a los programas
durante el proceso de enlazado.
2.2. Compilación, enlazado y depuración [31]
C2
Las bibliotecas pueden enlazarse contra el programa de dos formas:
Compilación
C2
Comprobación y resolución de símbolos y dependencias a nivel de declaración.
Realizar optimizaciones.
Ensamblador
Enlazador
GNU Linker Con todos los archivos objetos el enlazador (linker) es capaz de generar el eje-
cutable o código binario final. Algunas de las tareas que se realizan en el proceso de
GNU Linker también forma parte de enlazado son las siguientes:
la distribución GNU Binutils y se
corresponde con el programa ld.
Selección y filtrado de los objetos necesarios para la generación del binario.
Comprobación y resolución de símbolos y dependencias a nivel de definición.
Realización del enlazado (estático y dinámico) de las bibliotecas.
2.2.4. Ejemplos
Como se ha mostrado, el proceso de compilación está compuesto por varias fases
bien diferenciadas. Sin embargo, con GCC se integra todo este proceso de forma que,
a partir del código fuente se genere el binario final.
En esta sección se mostrarán ejemplos en los que se crea un ejecutable al que,
posteriormente, se enlaza con una biblioteca estática y otra dinámica.
[34] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Compilación de un ejecutable
1 #include <iostream>
2
3 using namespace std;
4
5 class Square {
6 private:
7 int side_;
8
9 public:
10 Square(int side_length) : side_(side_length) { };
11 int getArea() const { return side_*side_; };
12 };
13
14 int main () {
15 Square square(5);
16 cout << "Area: " << square.getArea() << endl;
17 return 0;
18 }
En C++, los programas que generan un ejecutable deben tener definida la fun-
ción main, que será el punto de entrada de la ejecución. El programa es trivial: se
define una clase Square que representa a un cuadrado. Ésta implementa un método
getArea() que devuelve el área del cuadrado.
Suponiendo que el archivo que contiene el código fuente se llama main.cpp,
para construir el binario utilizaremos g++, el compilador de C++ que se incluye en
GCC. Se podría utilizar gcc y que se seleccionara automáticamente el compilador.
Sin embargo, es una buena práctica utilizar el compilador correcto:
C2
Compilación de un ejecutable (modular)
Para construir el programa, se debe primero construir el código objeto del módulo
y añadirlo a la compilación de la función principal main. Suponiendo que el archivo
de cabecera se encuentra en un directorio llamado headers, la compilación puede
realizarse de la siguiente manera:
Con la opción -I, que puede aparecer tantas veces como sea necesario, se puede
añadir rutas donde se buscarán las cabeceras. Nótese que, por ejemplo, en main.cpp
se incluyen las cabeceras usando los símbolos <> y . Se recomienda utilizar los pri-
meros para el caso en que las cabeceras forman parte de una API pública (si existe) y
deban ser utilizadas por otros programas. Por su parte, las comillas se suelen utilizar
para cabeceras internas al proyecto. Las rutas por defecto son el directorio actual . pa-
ra las cabeceras incluidas con y para el resto el directorio del sistema (normalmente,
/usr/include).
Como norma general, una buena costumbre es generar todos los archivos de
código objeto de un módulo y añadirlos a la compilación con el programa
principal.
Para este ejemplo se supone que se pretende construir una biblioteca con la que se
pueda enlazar estáticamente y que contiene una jerarquía de clases correspondientes
a 3 tipos de figuras (Figure): Square, Triangle y Circle. Cada figura está
implementada como un módulo (cabecera + implementación):
C2
Listado 2.9: Triangle.h
1 #include <Figure.h>
2
3 class Triangle : public Figure {
4 private:
5 float base_;
6 float height_;
7
8 public:
9 Triangle(float base_, float height_);
10 float getArea() const;
11 };
Como se puede ver, se utiliza GCC directamente para generar la biblioteca dinámi-
ca. La compilación y enlazado con el programa principal se realiza de la misma forma
que en el caso del enlazado estático. Sin embargo, la ejecución del programa principal
es diferente. Al tratarse de código objeto que se cargará en tiempo de ejecución, exis-
ten una serie de rutas predefinadas donde se buscarán las bibliotecas. Por defecto, son
las mismas que para el proceso de enlazado.
También es posible añadir rutas modificando la variable LD_LIBRARY_PATH:
$ LD_LIBRARY_PATH=. ./main
2.2. Compilación, enlazado y depuración [39]
C2
2.2.5. Otras herramientas
La gran mayoría de las herramientas utilizadas hasta el momento forman parte
de la distribución GNU Binutils1 que se proporcionan en la mayoría de los sistemas
GNU/Linux. Existen otras herramientas que se ofrecen en este misma distribución y
que pueden ser de utilidad a lo largo del proceso de desarrollo:
GDB necesita información extra que, por defecto, GCC no proporciona para po-
der realizar las tareas de depuración. Para ello, el código fuente debe ser compilado
con la opción -ggdb. Todo el código objeto debe ser compilado con esta opción de
compilación, por ejemplo:
Para depurar no se debe hacer uso de las optimizaciones. Éstas pueden gene-
rar código que nada tenga que ver con el original.
C2
$ ./main
Main start
Function A: 24
Segmentation fault
Una violación de segmento (segmentation fault) es uno de los errores lógicos típi-
cos de los lenguajes como C++. El problema es que se está accediendo a una zona de
la memoria que no ha sido reservada para el programa, por lo que el sistema operativo
interviene denegando ese acceso indebido.
A continuación, se muestra cómo iniciar una sesión de depuración con GDB para
encontrar el origen del problema:
$ gdb main
...
Reading symbols from ./main done.
(gdb)
Como se puede ver, GDB ha cargado el programa, junto con los símbolos de depu-
ración necesarios, y ahora se ha abierto una línea de órdenes donde el usuario puede
especificar sus acciones.
Examinando el contenido
Abreviatura Para comenzar la ejecución del programa se puede utilizar la orden start:
Todas las órdenes de GDB pueden (gdb) start
escribirse utilizando su abreviatura. Temporary breakpoint 1 at 0x400d31: file main.cpp, line 26.
Ej: run = r. Starting program: main
(gdb) list
21 cout << "Return B: " << functionB("Hi", test) << endl;
22 return 5;
23 }
24
25 int main() {
26 cout << "Main start" << endl;
27 cout << "Return A: " << functionA(24) << endl;
28 return 0;
29 }
(gdb)
[42] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Como el resto de órdenes, list acepta parámetros que permiten ajustar su com-
portamiento.
Las órdenes que permiten realizar una ejecución controlada son las siguientes:
(gdb) s
Main start
27 cout << "Return A: " << functionA(24) << endl;
(gdb)
functionA (a=24) at main.cpp:18
18 cout << "Function A: " << a << endl;
(gdb)
En este punto se puede hacer uso de las órdenes para mostrar el contenido del
parámetro a de la función functionA():
(gdb) print a
$1 = 24
(gdb) print &a
$2 = (int *) 0x7fffffffe1bc
La ejecución está detenida en la línea 19 donde un comentario nos avisa del error.
Se está creando un puntero con el valor NULL. Posteriormente, se invoca un método
sobre un objeto que no está convenientemente inicializado, lo que provoca la violación
de segmento:
(gdb) next
20 test->setValue(15);
(gdb)
2.2. Compilación, enlazado y depuración [43]
C2
Program received signal SIGSEGV, Segmentation fault.
0x0000000000400df2 in Test::setValue (this=0x0, a=15) at main.cpp:8
8 void setValue(int a) { _value = a; }
Breakpoints
La ejecución paso a paso es una herramienta útil para una depuración de grano
fino. Sin embargo, si el programa realiza grandes iteraciones en bucles o es demasiado
grande, puede ser un poco incómodo (o inviable). Si se tiene la sospecha sobre el lugar
donde está el problema se pueden utilizar puntos de ruptura o breakpoints que permite
detener el flujo del programa en un punto determinado por el usuario.
Con el ejemplo ya arreglado, se configura un breakpoint en la función functionB()
y otro en la línea 28 con la orden break. A continuación, se ejecuta el programa hasta
que se alcance el breakpoint con la orden run (r):
¡No hace falta escribir todo!. Utiliza TAB para completar los argumentos de
una orden.
Con la orden continue (c) la ejecución avanza hasta el siguiente punto de rup-
tura (o fin del programa):
(gdb) continue
Continuing.
Function B: Hi, 15
Return B: 3.14
Return A: 5
(gdb)
Stack y frames
En muchas ocasiones, los errores vienen debidos a que las llamadas a funciones no
se realizan con los parámetros adecuados. Es común pasar punteros no inicializados o
valores incorrectos a una función/método y, por tanto, obtener un error lógico.
Para gestionar las llamadas a funciones y procedimientos, en C/C++ se utiliza la
pila (stack ). En la pila se almacenan frames, estructuras de datos que registran las
variables creadas dentro de una función así como otra información de contexto. GDB
permite manipular la pila y los frames de forma que sea posible identificar un uso
indebido de las funciones.
Con la ejecución parada en functionB(), se puede mostrar el contenido de la
pila con la orden backtrace (bt):
(gdb) backtrace
#0 functionB (str1=..., t=0x602010) at main.cpp:13
#1 0x0000000000400d07 in functionA (a=24) at main.cpp:21
#2 0x0000000000400db3 in main () at main.cpp:27
(gdb)
Con up y down se puede navegar por los frames de la pila, y con frame se puede
seleccionar uno en concreto:
(gdb) up
#1 0x0000000000400d07 in functionA (a=24) at gdb-fix.cpp:21
21 cout << "Return B: " << functionB("Hi", test) << endl;
(gdb)
#2 0x0000000000400db3 in main () at gdb-fix.cpp:27
27 cout << "Return A: " << functionA(24) << endl;
(gdb) frame 0
#0 functionB (str1=..., t=0x602010) at gdb-fix.cpp:13
13 cout << "Function B: " << str1 << ", " << t->getValue() << endl;
(gdb)
Una vez seleccionado un frame, se puede obtener toda la información del mismo, Invocar funciones
además de modificar las variables y argumentos:
La orden call se puede utilizar pa-
ra invocar funciones y métodos .
(gdb) print *t
$1 = {_value = 15}
(gdb) call t->setValue(1000)
(gdb) print *t
$2 = {_value = 1000}
(gdb)
2.2. Compilación, enlazado y depuración [45]
C2
Entornos gráficos para GDB
✄
GDB TUI: normalmente, la distribución ✄GDB
de
✄
incorpora una interfaz basada
en modo texto accesible pulsando ✂Ctrl ✁+ ✂x ✁y, a continuación, ✂a ✁.
ddd y xxgdb: las librerías gráficas utilizadas son algo anticuadas, pero facilitan
el uso de GDB.
gdb-mode: modo de Emacs para GDB. Dentro del modo se puede activar la
opción M-x many-windows para obtener buffers con toda la información
disponible.
kdbg: más atractivo gráficamente (para escritorios KDE).
Estructura
Existen algunos objetivos especiales como all, install y clean que sirven
como regla de partida inicial, para instalar el software construido y para limpiar del
proyecto los archivos generados, respectivamente.
Tomando como ejemplo la aplicación que hace uso de la biblioteca dinámica, el
siguiente listado muestra el Makefile que generaría tanto el programa ejecutable como
la biblioteca estática:
C2
$ make
$ make clean
Como ejercicio, se plantean las siguientes preguntas: ¿qué opción permite ejecutar
make sobre otro archivo que no se llame Makefile? ¿Se puede ejecutar make sobre
un directorio que no sea el directorio actual? ¿Cómo?.
GNU Coding Standars
Variables automáticas y reglas con patrones
En el proyecto GNU se definen los
objetivos que se esperan en un soft-
ware que siga estas directrices. Make se caracteriza por ofrecer gran versatilidad en su lenguaje. Las variables
automáticas contienen valores que dependen del contexto de la regla donde se aplican
y permiten definir reglas genéricas. Por su parte, los patrones permiten generalizar
las reglas utilizando el nombre los archivos generados y los fuentes.
A continuación se presenta una versión mejorada del anterior Makefile haciendo
uso de variables, variables automáticas y patrones:
Como ejercicio se plantean las siguientes cuestiones: ¿qué ocurre si una vez cons-
truido el proyecto se modifica algún fichero .cpp? ¿Y si se modifica una cabecera
.h? ¿Se podría construir una regla con patrón genérica para construir la biblioteca
estática? ¿Cómo lo harías?.
[48] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Reglas implícitas
Como se puede ver, Make puede generar automáticamente los archivos objeto .o
a partir de la coincidencia con el nombre del fichero fuente (que es lo habitual). Por
ello, no es necesario especificar cómo construir los archivos .o de la biblioteca, ni
siquiera la regla para generar main ya que asume de que se trata del ejecutable (al
existir un fichero llamado main.cpp).
Las variables de usuario que se han definido permiten configurar los flags de com-
pilación que se van a utilizar en las reglas explícitas. Así:
C2
Funciones
GNU Make proporciona un conjunto de funciones que pueden ser de gran ayuda
a la hora de construir los Makefiles. Muchas de las funciones están diseñadas para
el tratamiento de cadenas, ya que se suelen utilizar para transformar los nombres de
archivos. Sin embargo, existen muchas otras como para realizar ejecución condicional,
bucles y ejecutar órdenes de consola. En general, las funciones tienen el siguiente
formato:
$(nombre arg1,arg2,arg3,...)
Las funciones se pueden utilizar en cualquier punto del Makefile, desde las ac-
ciones de una regla hasta en la definición de un variable. En el siguiente listado se
muestra el uso de algunas de estas funciones:
$ DEBUG=yes make
Más información
GNU Make es una herramienta que está en continuo crecimiento y esta sección só-
lo ha sido una pequeña presentación de sus posibilidades. Para obtener más informa-
ción sobre las funciones disponibles, otras variables automáticas y objetivos predefini-
dos se recomiendo utilizar el manual en línea de Make2 , el cual siempre se encuentra
actualizado.
C2
Por otro lado, sería interesante tener la posibilidad de realizar desarrollos en para-
lelo de forma que exista una versión «estable» de todo el proyecto y otra más «experi-
mental» del mismo donde se probaran diferentes algoritmos y diseños. De esta forma,
probar el impacto que tendría nuevas implementaciones sobre el proyecto no afec-
taría a una versión más «oficial». También es común que se desee añadir una nueva
funcionalidad y ésta se realiza en paralelo junto con otros desarrollos.
Los sistemas de control de versiones o Version Control System (VCS) permiten
gestionar los archivos de un proyecto (y sus versiones) y que sus integrantes puedan
acceder remotamente a ellos para descargarlos, modificarlos y publicar los cambios.
También se encargan de detectar posibles conflictos cuando varios usuarios modifican
los mismos archivos y de proporcionar un sistema básico de registro de cambios.
Como norma general, al VCS debe subirse el archivo fuente y nunca el archivo
generado. No se deben subir binarios ya que no es fácil seguir la pista a sus
modificaciones.
Existen diferentes criterios para clasificar los diferentes VCS existentes. Uno de
los que más influye tanto en la organización y uso del repositorio es si se trata de VCS
centralizado o distribuido. En la figura 2.6 se muestra un esquema de ambas filosofías.
Los VCS centralizados como CVS o Subversion se basan en que existe un nodo
servidor con el que todos los clientes conectan para obtener los archivos, subir modifi-
caciones, etc. La principal ventaja de este esquema reside en su sencillez: las diferentes
versiones del proyecto están únicamente en el servidor central, por lo que los posibles
conflictos entre las modificaciones de los clientes pueden detectarse y gestionarse más
fácilmente. Sin embargo, el servidor es un único punto de fallo y en caso de caída, los
clientes quedan aislados.
Por su parte, en los VCS distribuidos como Mercurial o Git, cada cliente tiene un
repositorio local al nodo en el que se suben los diferentes cambios. Los cambios pue-
den agruparse en changesets, lo que permite una gestión más ordenada. Los clientes
actúan de servidores para el resto de los componentes del sistema, es decir, un cliente
puede descargarse una versión concreta de otro cliente.
Esta arquitectura es tolerante a fallos y permite a los clientes realizar cambios sin
necesidad de conexión. Posteriormente, pueden sincronizarse con el resto. Aún así,
un VCS distribuido puede utilizarse como uno centralizado si se fija un nodo como
servidor, pero se perderían algunas posibilidades que este esquema ofrece.
[52] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Subversion
Inicialmente, los clientes pueden descargarse el repositorio por primera vez utili-
zando la orden checkout:
$ mkdir doc
$ echo "This is a new file" > doc/new_file
$ echo "Other file" > other_file
$ svn add doc other_file
A doc
A doc/new_file
A other_file
La operación add indica qué archivos y directorios han sido seleccionados para
ser añadidos al repositorio (marcados con A). Esta operación no sube efectivamente
los archivos al servidor. Para subir cualquier cambio se debe hacer un commit:
$ svn commit
C2
$ svn update -r REVISION
$ svn update
C other_file
At revision X+1.
Nótese que este commit sólo añade los cambios hechos en new_file, aceptando
los cambios en other_file que hizo user2.
Mercurial
$ hg init /home/user1/myproyect
Al igual que ocurre con Subversion, este directorio debe ser accesible median-
te algún mecanismo (preferiblemente, que sea seguro) para que el resto de usuarios
Figura 2.8: Logotipo del proyecto pueda acceder. Sin embargo, el usuario user1 puede trabajar directamente sobre ese
Mercurial. directorio.
[54] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Para obtener una versión inicial, otro usuario (user2) debe clonar el repositorio.
Basta con ejecutar lo siguiente:
$ hg clone ssh://user2@host//home/user1/myproyect
A partir de este instante, user2 tiene una versión inicial del proyecto extraída a
partir de la del usuario user1. De forma muy similar a Subversion, con la orden add
se pueden añadir archivos y directorios.
Mientras que en el modelo de Subversion, los clientes hacen commit y update
para subir cambios y obtener la última versión, respectivamente; en Mercurial es algo
más complejo, ya que existe un repositorio local. Como se muestra en la figura 2.9, la
operación commit (3) sube los cambios a un repositorio local que cada cliente tiene.
Cada commit se considera un changeset, es decir, un conjunto de cambios agrupa-
dos por un mismo ID de revisión. Como en el caso de Subversion, en cada commit se
pedirá una breve descripción de lo que se ha modificado.
Una vez hecho todos commits, para llevar estos cambios a un servidor remoto se
debe ejecutar la orden de push (4). Siguiendo con el ejemplo, el cliente user2 lo
enviará por defecto al repositorio del que hizo la operación clone.
El sentido inverso, es decir, traerse los cambios del servidor remoto a la copia
local, se realiza también en 2 pasos: pull (1) que trae los cambios del repositorio
remoto al repositorio local; y update (2), que aplica dichos cambios del repositorio
local al directorio de trabajo. Para hacer los dos pasos al mismo tiempo, se puede hacer
lo siguiente:
$ hg pull -u
Para evitar conflictos con otros usuarios, una buena costumbre antes de reali-
zar un push es conveniente obtener los posibles cambios en el servidor con
pull y update.
2.3. Gestión de proyectos y documentación [55]
C2
Para ver cómo se gestionan los conflictos en Mercurial, supóngase que user1
realiza lo siguiente:
Git
Diseñado y desarrollado por Linus Torvalds para el proyecto del kernel Linux, Git
es un VCS distribuido que cada vez es más utilizado por la comunidad de desarrolla-
dores. En términos generales, tiene una estructura similar a Mercurial: independencia
entre repositorio remotos y locales, gestión local de cambios, etc.
Sin embargo, Git es en ocasiones preferido sobre Mercurial por algunas de sus
características propias:
[56] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
Cada head apunta a un commit y tiene un nombre simbólico para poder ser re-
ferenciado. Por defecto, todos los respositorios Git tienen un head llamado master.
HEAD (nótese todas las letras en mayúscula) es una referencia al head usado en cada
instante. Por lo tanto, en un repositorio Git, en un estado sin cambios, HEAD apuntará
al master del respositorio.
Para crear un repositorio donde sea posible que otros usuarios puedan subir cam-
bios se utiliza la orden init:
C2
Figura 2.11: Esquema del flujo de trabajo básico en Git
$ git status
# On branch master
nothing to commit, working directory clean
Nótese como la última llamada a status no muestra ningún cambio por subir al
repositorio local. Esto significa que la copia de trabajo del usuario está sincronizada
con el repositorio local (todavía no se han realizado operaciones con el remoto). Se
puede utilizar reflog para ver la historia:
[58] CAPÍTULO 2. HERRAMIENTAS DE DESARROLLO
$ git reflog
2f81676 HEAD@{0}: commit: Test example: initial version
...
diff se utiliza para ver cambios entre commits, ramas, etc. Por ejemplo, la si-
guiente orden muestra las diferencias entre master del repositorio local y master del
remoto:
Para modificar código y subirlo al repositorio local se sigue el mismo procedi- gitk
miento: (1) realizar la modificación, (2) usar add para añadir el archivo cambiado,
(3) hacer commit para subir el cambio al repositorio local. Sin embargo, como se ha Para entender mejor estos concep-
dicho anteriormente, una buena característica de Git es la creación y gestión de ramas tos y visualizarlos durante el proce-
(branches) locales que permiten hacer un desarrollo en paralelo. Esto es muy útil ya so de desarrollo, existen herramien-
tas gráficas como gitk que permi-
que, normalmente, en el ciclo de vida de desarrollo de un programa se debe simulta- ten ver todos los commits y heads
near tanto la creación de nuevas características como arreglos de errores cometidos. en cada instante.
En Git, estas ramas no son más que referencias a commits, por lo que son muy ligeras
y pueden llevarse de un sitio a otro de forma sencilla.
Como ejemplo, la siguiente secuencia de órdenes crea una rama local a partir del
HEAD, modifica un archivo en esa rama y finalmente realiza un merge con master:
$ git branch
* NEW-BRANCH
master
Nótese cómo la orden branch muestra la rama actual marcada con el símbolo
*. Utilizando las órdenes log y show se pueden listar los commits recientes. Estas
órdenes aceptan, además de identificadores de commits, ramas y rangos temporales
de forma que pueden obtenerse gran cantidad de información de ellos.
Finalmente, para desplegar los cambios en el repositorio remoto sólo hay que uti-
lizar:
$ git push
2.3. Gestión de proyectos y documentación [59]
C2
Git utiliza ficheros como .gitconfig y .gitignore para cargar confi-
guraciones personalizadas e ignorar ficheros a la hora de hacer los commits,
respectivamente. Son muy útiles. Revisa la documentación de git-config
y gitignore para más información.
2.3.2. Documentación
Uno de los elementos más importantes que se generan en un proyecto es la do-
cumentación: cualquier elemento que permita entender mejor tanto el proyecto en su
totalidad como sus partes, de forma que facilite el proceso de mantenimiento en el
futuro. Además, una buena documentación hará más sencilla la reutilización de com-
ponentes.
Existen muchos formatos de documentación que pueden servir para un proyecto
software. Sin embargo, muchos de ellos, tales como PDF, ODT, DOC, etc., son for-
matos «binarios» por lo que no son aconsejables para utilizarlos en un VCS. Además,
utilizando texto plano es más sencillo crear programas que automaticen la generación
de documentación, de forma que se ahorre tiempo en este proceso.
Por ello, aquí se describen algunas formas de crear documentación basadas en
texto plano. Obviamente, existen muchas otras y, seguramente, sean tan válidas como
las que se proponen aquí.
Doxygen
8 /**
9 \param s the name of the Test.
10 */
11 Test(string s);
12
13 /// Start running the test.
14 /**
15 \param max maximum time of test delay.
16 \param silent if true, do not provide output.
17 \sa Test()
18 */
19 int run(int max, bool silent);
20 };
$ doxygen .
reStructuredText
C2
30 | row 2 | | | |
31 +--------------+----------+-----------+-----------+
32
33 Images
34 ------
35
36 .. image:: gnu.png
37 :scale: 80
38 :alt: A title text
Como se puede ver, aunque RST añade una sintaxis especial, el texto es completa-
mente legible. Ésta es una de las ventajas de RST, el uso de etiquetas de formato que
no «ensucian» demasiado el texto.
YAML
Planificación y gestión de tareas: permite anotar qué tareas quedan por hacer
y los plazos de entrega. También suelen permitir asignar prioridades.
Planificación y gestión de recursos: ayuda a controlar el grado de ocupación
del personal de desarrollo (y otros recursos).
Seguimiento de fallos: también conocido como bug tracker, es esencial para
llevar un control sobre los errores encontrados en el programa. Normalmente,
permiten gestionar el ciclo de vida de un fallo, desde que se descubre hasta que
se da por solucionado.
Foros: normalmente, las forjas de desarrollo permiten administrar varios foros
de comunicación donde con la comunidad de usuarios del programa pueden
escribir propuestas y notificar errores.
Las forjas de desarrollo suelen ser accesibles via web, de forma que sólo sea ne-
cesario un navegador para poder utilizar los diferentes servicios que ofrece. Depen-
diendo de la forja de desarrollo, se ofrecerán más o menos servicios. Sin embargo, los
expuestos hasta ahora son los que se proporcionan habitualmente. Existen forjas gra-
tuitas en Internet que pueden ser utilizadas para la creación de un proyecto. Algunas
de ellas:
C2
SourceForge9 : probablemente, una de las forjas gratuitas más conocidas. Pro-
piedad de la empresa GeekNet Inc., soporta Subversion, Git, Mercurial, Bazaar
y CVS.
Google Code10 : la forja de desarrollo de Google que soporta Git, Mercurial y
Subversion.
Redmine
9 http://sourceforge.net
10 http://code.google.com
C++. Aspectos Esenciales
Capítulo 3
David Vallejo Fernández
E
l lenguaje más utilizado para el desarrollo de videojuegos comerciales es C++,
debido especialmente a su potencia, eficiencia y portabilidad. En este capítulo
se hace un recorrido por C++ desde los aspectos más básicos hasta las he-
rramientas que soportan la POO (Programación Orientada a Objetos) y que permiten
diseñar y desarrollar código que sea reutilizable y mantenible, como por ejemplo las
plantillas y las excepciones.
POO En esta sección se realiza un recorrido por los aspectos básicos de C++, hacien-
do especial hincapié en aquellos elementos que lo diferencian de otros lenguajes de
La programación orientada a obje- programación y que, en ocasiones, pueden resultar más complicados de dominar por
tos tiene como objetivo la organi-
zación eficaz de programas. Bási- aquellos programadores inexpertos en C++.
camente, cada componente es un
objeto autocontenido que tiene una
serie de operaciones y de datos o
estado. Este planteamiento permi-
3.1.1. Introducción a C++
te reducir la complejidad y gestio-
nar grandes proyectos de programa- C++ se puede considerar como el lenguaje de programación más importante en la
ción. actualidad. De hecho, algunas autores relevantes [81] consideran que si un programa-
dor tuviera que aprender un único lenguaje, éste debería ser C++. Aspectos como su
sintaxis y su filosofía de diseño definen elementos clave de programación, como por
ejemplo la orientación a objetos. C++ no sólo es importante por sus propias caracterís-
ticas, sino también porque ha sentado las bases para el desarrollo de futuros lenguajes
de programación. Por ejemplo, Java o C# son descendientes directos de C++. Desde el
punto de vista profesional, C++ es sumamente importante para cualquier programador.
65
[66] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
El origen de C++ está ligado al origen de C, ya que C++ está construido sobre
C. De hecho, C++ es un superconjunto de C y se puede entender como una versión
extendida y mejorada del mismo que integra la filosofía de la POO y otras mejoras,
como por ejemplo la inclusión de un conjunto amplio de bibliotecas. Algunos autores
consideran que C++ surgió debido a la necesidad de tratar con programas de mayor
complejidad, siendo impulsado en gran parte por la POO.
C++ fue diseñado por Bjarne Stroustrup1 en 1979. La idea de Stroustrup fue
añadir nuevos aspectos y mejoras a C, especialmente en relación a la POO, de manera
que un programador de C sólo que tuviera que aprender aquellos aspectos relativos a
la OO.
En el caso particular de la industria del videojuego, C++ se puede considerar co-
mo el estándar de facto debido principalmente a su eficiencia y portabilidad. C++ es
una de los pocos lenguajes que posibilitan la programación de alto nivel y, de manera
simultánea, el acceso a los recursos de bajo nivel de la plataforma subyacente. Por lo
tanto, C++ es una mezcla perfecta para la programación de sistemas y para el desarro-
llo de videojuegos. Una de las principales claves a la hora de manejarlo eficientemente
en la industria del videojuego consiste en encontrar el equilibrio adecuado entre efi-
ciencia, fiabilidad y mantenibilidad [27].
Figura 3.1: Bjarne Stroustrup,
creador del lenguaje de programa-
3.1.2. ¡Hola Mundo! en C++ ción C++ y personaje relevante en
el ámbito de la programación.
A continuación se muestra el clásico ¡Hola Mundo! implementado en C++. En
este primer ejemplo, se pueden apreciar ciertas diferencias con respecto a un programa Dominar C++
escrito en C. Una de las mayores ventajas de
C++ es que es extremadamente po-
tente. Sin embargo, utilizarlo efi-
Listado 3.1: Hola Mundo en C++ cientemente es díficil y su curva de
aprendizaje no es gradual.
1 /* Mi primer programa con C++. */
2
3 #include <iostream>
4 using namespace std;
5
6 int main () {
7
8 string nombre;
9
10 cout << "Por favor, introduzca su nombre... ";
11 cin >> nombre;
12 cout << "Hola " << nombre << "!"<< endl;
13
14 return 0;
15
16 }
✄
La directiva include de la línea ✂3 ✁incluye la biblioteca
✄ <iostream>, la cual soporta
el sistema de E/S de C++. A continuación, en la ✂4 ✁el programa le indica al compila-
dor que utilice el espacio de nombres std, en el que se declara la biblioteca estándar de
C++. Un espacio de nombres delimita una zona de declaración en la que incluir dife-
rentes elementos de un programa. Los espacios de nombres siguen la misma filosofía
que los paquetes en Java y tienen como objetivo organizar los programas. El hecho de
utilizar un espacio de nombres permite acceder a sus elementos y funcionalidades sin
tener que especificar a qué espacio pertenecen.
1 http://www2.research.att.com/~bs/
3.1. Utilidades básicas [67]
✄
En la línea ✂10 ✁de hace uso de cout (console output), la sentencia de salida por
C3
consola junto con el operador <<, redireccionando lo que queda a su derecha, es
decir, Por favor, introduzca su nombre..., hacia la salida por consola. A continuación,
en la siguiente línea se hace uso de cin (console input) junto con el operador >>
para redirigir la entrada proporcionado por teclado a la variable nombre. Note que
dicha variable es de tipo cadena, un tipo de datos de C++ que se define✄ como
un array
de caracteres finalizado con el carácter null. Finalmente, en la línea ✂12 ✁se saluda al
lector, utilizando además la sentencia endl para añadir un retorno de carro a la salida
y limpiar el buffer.
int *ip;
edadptr = &edad;
miEdad = *edadptr
En C++ existe una relación muy estrecha entre los punteros y los arrays, siendo
posible intercambiarlos en la mayoría de casos. El siguiente listado de código muestra
un sencillo ejemplo de indexación de un array mediante aritmética de punteros.
✄
En la inicialización del bucle for de la línea ✂10 ✁se aprecia cómo se asigna la direc-
ción de inicio del array s al puntero p para, posteriormente, indexar el array mediante
p para resaltar en mayúsculas el contenido del array una vez finalizada la ejecución
del bucle. Note que C++ permite la inicialización múltiple.
3.1. Utilidades básicas [69]
C3
Los punteros son enormemente útiles y potentes. Sin embargo, cuando un puntero
almacena, de manera accidental, valores incorrectos, el proceso de depuración puede
resultar un auténtico quebradero de cabeza. Esto se debe a la propia naturaleza del
puntero y al hecho de que indirectamente afecte a otros elementos de un programa, lo
cual puede complicar la localización de errores.
El caso típico tiene lugar cuando un puntero apunta a algún lugar de memoria que
no debe, modificando datos a los que no debería apuntar, de manera que el programa
muestra resultados indeseables posteriormente a su ejecución inicial. En estos casos,
cuando se detecta el problema, encontrar la evidencia del fallo no es una tarea trivial,
ya que inicialmente puede que no exista evidencia del puntero que provocó dicho
error. A continuación se muestran algunos de los errores típicos a la hora de manejar
punteros [81].
1. No inicializar punteros. En el listado que se muestra a continuación, p contiene
una dirección desconocida debido a que nunca fue definida. En otras palabras, no es
posible conocer dónde se ha escrito el valor contenido en edad.
No olvide que una de las claves para garantizar un uso seguro de los punteros
consiste en conocer en todo momento hacia dónde están apuntando.
C3
2 using namespace std;
3
4 struct persona {
5 string nombre;
6 int edad;
7 };
8
9 void modificar_nombre (persona *p, const string& nuevo_nombre);
10
11 int main () {
12 persona p;
13 persona *q;
14
15 p.nombre = "Luis";
16 p.edad = 23;
17 q = &p;
18
19 cout << q->nombre << endl;
20 modificar_nombre(q, "Sergio");
21 cout << q->nombre << endl;
22
23 return 0;
24 }
25
26 void modificar_nombre (persona *p, const string& nuevo_nombre) {
27 p->nombre = nuevo_nombre;
28 }
C3
Las funciones también pueden devolver referencias. En C++, una de las mayores
utilidades de esta posibilidad es la sobrecarga de operadores. Sin embargo, en el lis-
tado que se muestra a continuación se refleja otro uso potencial, debido a que cuando
se devuelve una referencia, en realidad se está devolviendo un puntero implícito al
valor de retorno. Por lo tanto, es posible utilizar la función en la parte izquierda de
una asignación.
Funciones y referencias Como se puede apreciar en el siguiente listado, la función f devuelve una referen-
cia a un valor en punto flotante de doble precisión, en ✄ concreto
a la variable global
En una función, las referencias se valor. La parte importante del código está en la línea ✂15 ✁, en la que valor se actualiza
pueden utilizar como parámetros de
entrada o como valores de retorno. a 7,5, debido a que la función devuelve dicha referencia.
Aunque se discutirá más adelante, las referencias también se pueden utilizar para
devolver objetos desde una función de una manera eficiente. Sin embargo, hay que
ser cuidadoso con la referencia a devolver, ya que si se asigna a un objeto, entonces
se creará una copia. El siguiente fragmento de código muestra un ejemplo represen-
tativo vinculado al uso de matrices de 16 elementos, estructuras de datos típicamente
utilizada en el desarrollo de videojuegos.
Las ventajas de las referencias sobre los punteros se pueden resumir en que utili-
zar referencias es una forma de más alto nivel de manipular objetos, ya que permite al
desarrollador olvidarse de los detalles de gestión de memoria y centrarse en la lógica
del problema a resolver. Aunque pueden darse situaciones en las que los punteros son
más adecuados, una buena regla consiste en utilizar referencias siempre que sea posi-
ble, ya que su sintaxis es más limpia que la de los punteros y su uso es menos proclive
a errores.
3.2. Clases
C3
Listado 3.10: Clase Figura
1 class Figura
2 {
3 public:
4 Figura (double i, double j);
5 ~Figura ();
6
7 void setDim (double i, double j);
8 double getX () const;
9 double getY () const;
10
11 protected:
12 double _x, _y;
13 };
Note cómo las variables de clase se definen como protegidas, es decir, con una vi-
sibilidad privada fuera de dicha clase a excepción de las clases que hereden de Figura,
tal y como se discutirá en la sección 3.3.1. El constructor y el destructor comparten el
nombre con la clase, pero el destructor tiene delante el símbolo ~.
Paso por referencia El resto de funciones sirven para modificar y acceder al estado de los objetos ins-
tanciados a partir de dicha clase. Note el uso del modificador const en las funciones
Recuerde utilizar parámetros por de acceso getX() y getY(), con el objetivo de informar de manera explícita al compila-
referencia const para minimizar el
número de copias de los mismos. dor de que dichas funciones no modifican el estado de los objetos. A continuación, se
muestra la implementación de las funciones definidas en la clase Figura.
Uso de inline Antes de continuar discutiendo más aspectos de las clases, resulta interesante in-
El modificador inline se suele in- troducir brevemente el concepto de funciones en línea (inlining), una técnica que
cluir después de la declaración de la puede reducir la sobrecarga implícita en las llamadas a funciones. Para ello, sólo es
función para evitar líneas de código necesario incluir el modificador inline delante de la declaración de una función. Es-
demasiado largas (siempre dentro
del archivo de cabecera). Sin em-
ta técnica permite obtener exactamente el mismo rendimiento que el acceso directo
bargo, algunos compiladores obli- a una variable sin tener que desperdiciar tiempo en ejecutar la llamada a la función,
gan a incluirlo en ambos lugares. interactuar con la pila del programa y volver de dicha función.
Las funciones en línea no se pueden usar indiscriminadamente, ya que pueden
degradar el rendimiento de la aplicación fácilmente. En primer lugar, el tamaño del
ejecutable final se puede disparar debido a la duplicidad de código. Así mismo, la
caché de código también puede hacer que dicho rendimiento disminuya debido a las
continuas penalizaciones asociadas a incluir tantas funciones en línea. Finalmente, los
tiempos de compilación se pueden incrementar en grandes proyectos.
[76] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
Una buena regla para usar de manera adecuada el modificador inline consiste
en evitar su uso hasta prácticamente completar el desarrollo de un proyecto. A
continuación, se puede utilizar alguna herramienta de profiling para detectar
si alguna función sencilla está entre las más utilidas. Este tipo de funciones
son candidatas potenciales para modificarlas con inline y, en consecuencia,
elementos para mejorar el rendimiento del programa.
Al igual que ocurre con otros tipos de datos, los objetos también se pueden mani-
pular mediante punteros. Simplemente se ha de utilizar la misma notación y recordar
que la aritmética de punteros también se puede usar con objetos que, por ejemplo,
formen parte de un array.
Para los objetos creados en memoria dinámica, el operador new invoca al cons-
tructor de la clase de manera que dichos objetos existen hasta que explícitamente
se eliminen con el operador delete sobre los punteros asociados. A continuación se
muestra un listado de código que hace uso de la clase Figura previamente introducida.
C3
2 #include "Figura.h"
3 using namespace std;
4
5 int main () {
6 Figura *f1;
7 f1 = new Figura (1.0, 0.5);
8
9 cout << "[" << f1->getX() << ", " << f1->getY() << "]" << endl;
10
11 delete f1;
12 return 0;
13 }
Construyendo...
7
Destruyendo...
Destruyendo...
[78] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
✄
Como se puede apreciar, existe una llamada al constructor al crear a (línea ✂24 ✁) y
dos llamadas al destructor. Como se ha comentado antes, cuando un objeto se pasa a
una función, entonces se crea una copia del mismo, la cual se destruye cuando finaliza
la ejecución de la función. Ante esta situación surgen dos preguntas: i) ¿se realiza una
llamada al constructor? y ii) ¿se realiza una llamada al destructor?
En realidad, lo que ocurre cuando se pasa un objeto a una función es que se llama
al constructor de copia, cuya responsabilidad consiste en definir cómo se copia un
objeto. Si una clase no tiene un constructor de copia, entonces C++ proporciona uno
por defecto, el cual crea una copia bit a bit del objeto. En realidad, esta decisión es
bastante lógica, ya que el uso del constructor normal para copiar un objeto no generaría
el mismo resultado que el estado que mantiene el objeto actual (generaría una copia
con el estado inicial).
Sin embargo, cuando una función finaliza y se ha de eliminar la copia del objeto,
entonces se hace uso del destructor debido a que la copia se encuentra fuera de su
ámbito local. Por lo tanto, en el ejemplo anterior se llama al destructor tanto para la
copia como para el argumento inicial.
C3
Listado 3.14: Paso de objetos por valor
1 #include <iostream>
2 using namespace std;
3
4 class A {
5 int _valor;
6 public:
7 A(int valor): _valor(valor) {
8 cout << "Construyendo..." << endl;
9 }
10 ~A() {
11 cout << "Destruyendo..." << endl;
12 }
13
14 int getValor () const {
15 return _valor;
16 }
17 };
18
19 void mostrar (A a) {
20 cout << a.getValor() << endl;
21 }
22
23 int main () {
24 A a(7);
25 mostrar(a);
26 return 0;
27 }
C3
7
Destruyendo...
Destruyendo...
✄ ✄
El constructor de copia se define entre las líneas ✂18 ✁y ✂21 ✁, reservando una nueva
C3
región de memoria para el contenido
✄ de _valor y copiando el mismo en la variable
miembro. Por otra parte, las líneas ✂23-26 ✁muestran la implementación de la función
set, que modifica el contenido de dicha variable miembro.
El siguiente listado muestra la implementación de la función entrada, que pide
una cadena por teclado y devuelve un objeto que alberga la entrada proporcionada por
el usuario.
En primer lugar, el programa se comporta adecuadamente cuando se llama a entra-
da, particularmente cuando se devuelve la copia del objeto a, utilizando el constructor
de copia previamente definido. Sin embargo, el programará abortará abruptamente
cuando el objeto devuelto por entrada se asigna a obj en la función principal. Recuer-
de que en este caso se efectua una copia idéntica. El problema reside en que obj.valor
apunta a la misma dirección de memoria que el objeto temporal, y éste último se des-
truye después de volver desde entrada, por lo que obj.valor apunta a memoria que
acaba de ser liberada. Además, obj.valor se vuelve a liberar al finalizar el programa.
3.3.1. Herencia
El siguiente listado de código muestra la clase base Vehículo que, desde un punto
de vista general, define un medio de transporte por carretera. De hecho, sus variables
miembro son el número de ruedas y el número de pasajeros.
Herencia y acceso
La clase base anterior se puede extender para definir coches con una nueva carac-
terística propia de los mismos, como se puede apreciar en el siguiente listado. El modificador de acceso cuando se
✄
usa herencia es opcional. Sin em-
En este ejemplo no se han definido los constructores de manera intencionada para
discutir el acceso a los miembros de la clase. Como se puede apreciar en la línea ✂5 ✁
bargo, si éste se especifica ha de ser
public, protected o private. Por
del siguiente listado, la clase Coche hereda de la clase Vehículo, utilizando el operador defecto, su valor es private si la
clase derivada es efectivamente una
:. La palabra reservada public delante de Vehículo determina el tipo de acceso. En clase. Si la clase derivada es una es-
este caso concreto, el uso de public implica que todos los miembros públicos de la tructura, entonces su valor por de-
fecto es public.
3.3. Herencia y polimorfismo [85]
C3
Listado 3.21: Clase derivada Coche
1 #include <iostream>
2 #include "Vehiculo.cpp"
3 using namespace std;
4
5 class Coche : public Vehiculo {
6 int _PMA;
7
8 public:
9 void setPMA (int PMA) {_PMA = PMA;}
10 int getPMA () const {return _PMA;}
11
12 void mostrar () const {
13 cout << "Ruedas: " << getRuedas() << endl;
14 cout << "Pasajeros: " << getPasajeros() << endl;
15 cout << "PMA: " << _PMA << endl;
16 }
17 };
clase base serán también miembros públicos de la clase derivada. En otras palabras, el
efecto que se produce equivale a que los miembros públicos de Vehículo se hubieran
declarado dentro de Coche. Sin embargo, desde Coche no es posible acceder a los
miembros privados de Vehículo, como por ejemplo a la variable _ruedas.
El caso contrario a la herencia pública es la herencia privada. En este caso, cuando
la clase base se hereda con private, entonces todos los miembros públicos de la clase
base se convierten en privados en la clase derivada.
Además de ser público o privado, un miembro de clase se puede definir como
protegido. Del mismo modo, una clase base se puede heredar como protegida. Si un
miembro se declara como protegido, dicho miembro no es accesible por elementos que
no sean miembros de la clase salvo en una excepción. Dicha excepción consiste en he-
redar un miembro protegido, hecho que marca la diferencia entre private y protected.
En esencia, los miembros protegidos de la clase base se convierten en miembros pro-
tegidos de la clase derivada. Desde otro punto de vista, los miembros protegidos son
miembros privados de una clase base pero con la posibilidad de heredarlos y acceder
a ellos por parte de una clase derivada. El siguiente listado de código muestra el uso
de protected.
Otro caso particular que resulta relevante comentar se da cuando una clase ba-
se se hereda como privada. En este caso, los miembros protegidos se heredan como
miembros privados en la clase protegida.
Si una clase base se hereda como protegida mediante el modificador de acceso pro-
tected, entonces todos los miembros públicos y protegidos de dicha clase se heredan
como miembros protegidos en la clase derivada.
Constructores y destructores
Cuando se hace uso de herencia y se definen constructores y/o destructores de Inicialización de objetos
clase, es importante conocer el orden en el que se ejecutan en el caso de la clase
base y de la clase derivada, respectivamente. Básicamente, a la hora de construir un El constructor de una clase debería
inicializar idealmente todo el esta-
objeto de una clase derivada, primero se ejecuta el constructor de la clase base y, a do de los objetos instanciados. Uti-
continuación, el constructor de la derivada. En el caso de la destrucción de objetos, lice el constructor de la clase base
el orden se invierte, es decir, primero se ejecuta el destructor de la clase derivada y, a cuando así sea necesario.
continuación, el de la clase base.
Otro aspecto relevante está vinculado al paso de parámetros al constructor de la
clase base desde el constructor de la clase derivada. Para ello, simplemente se realiza
una llamada al constructor de la clase base, pasando los argumentos que sean nece-
sarios. Este planteamiento es similar al utilizado en Java mediante super(). Note que
aunque una clase derivada no tenga variables miembro, en su constructor han de es-
pecificarse aquellos parámetros que se deseen utilizar para llamar al constructor de la
clase base.
C3
El enfoque todo en uno
Una primera opción de diseño podría consistir en aplicar un enfoque todo en uno,
es decir, implementar los requisitos previamente comentados en la propia clase Ob-
jetoJuego, añadiendo la funcionalidad de recepción de mensajes y la posibilidad de
enlazar el objeto en cualquier parte del árbol a la propia clase.
Aunque la simplicidad de esta aproximación es su principal ventaja, en general
añadir todo lo que se necesita en una única clase no es la mejor decisión de diseño. Si
se utiliza este enfoque para añadir más funcionalidad, la clase crecerá en tamaño y en
complejidad cada vez que se integre un nuevo requisito funcional. Así, una clase base
que resulta fundamental en el diseño de un juego se convertirá en un elemento difícil
de utilizar y de mantener. En otras palabras, la simplicidad a corto plazo se transforma
en complejidad a largo plazo.
Otro problema concreto con este enfoque es la duplicidad de código, ya que la
clase ObjetoJuego puede no ser la única en recibir mensajes, por ejemplo. La clase
Jugador podría necesitar recibir mensajes sin ser un tipo particular de la primera clase.
En el caso de enlazar con una estructura de árbol se podría dar el mismo problema, ya
que otros elementos del juego, como por ejemplo los nodos de una escena se podría
organizar del mismo modo y haciendo uso del mismo tipo de estructura de árbol.
En este contexto, copiar el código allí donde sea necesario no es una solución viable
debido a que complica enormemente el mantenimiento y afecta de manera directa a la
arquitectura del diseño.
Contenedores La conclusión directa que se obtiene al reflexionar sobre el anterior enfoque es que
resulta necesario diseñar sendas clases, ReceptorMensajes y NodoArbol, para repre-
La aplicación de un esquema basa-
do en agregación, de manera que sentar la funcionalidad previamente discutida. La cuestión reside en cómo relacionar
una clase contiene elementos rele- dichas clases con la clase ObjetoJuego.
vantes vinculados a su funcionali-
dad, es en general un buen diseño. Una opción inmediata podría ser la agregación, de manera que un objeto de la cla-
se ObjetoJuego contuviera un objeto de la clase ReceptorMensajes y otro de la clase
NodoArbol, respectivamente. Así, la clase ObjetoJuego sería responsable de propor-
cionar la funcionalidad necesaria para manejarlos en su propia interfaz. En términos
generales, esta solución proporciona un gran nivel de reutilización sin incrementar de
manera significativa la complejidad de las clases que se extienden de esta forma.
El siguiente listado de código muestra una posible implementación de este diseño.
La desventaja directa de este enfoque es la generación de un gran número de fun-
ciones que simplemente llaman a la función de una variable miembro, las cuales han
de crearse y mantenerse. Si unimos este hecho a un cambio en su interfaz, el man-
tenimiento se complica aún más. Así mismo, se puede producir una sobrecarga en el
número de llamadas a función, hecho que puede reducir el rendimiento de la aplica-
ción.
Una posible solución a este problema consiste en exponer los propios objetos en
lugar de envolverlos con llamadas a funciones miembro. Este planteamiento simpli-
fica el mantenimiento pero tiene la desventaja de que proporciona más información
de la realmente necesaria en la clase ObjetoJuego. Si además, posteriormente, es ne-
cesario modificar la implementación de dicha clase con propósitos de incrementar la
Uso de la herencia eficiencia, entonces habría que modificar todo el código que haga uso de la misma.
Recuerde utilizar la herencia con
prudencia. Un buen truco consiste
en preguntarse si la clase derivada
es un tipo particular de la clase ba-
se.
[88] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
Nodo
Árbol
Receptor Nodo Objeto
Mensajes Árbol Juego
Objeto
Juego
Figura 3.2: Distintas soluciones de diseño para el problema de la clase ObjetoJuego. (a) Uso de agregación.
(b) Herencia simple. (c) Herencia múltiple.
Otra posible solución de diseño consiste en usar herencia simple, es decir, Ob-
jetoJuego se podría declarar como una clase derivada de ReceptorMensajes, aunque
NodoArbol quedaría aislado. Si se utiliza herencia simple, entonces una alternativa se-
ría aplicar una cadena de herencia, de manera que, por ejemplo, ArbolNodo hereda de
ReceptorMensajes y, a su vez, ObjetoJuego hereda de ArbolNodo (ver figura 3.2.b).
3.3. Herencia y polimorfismo [89]
C3
Aunque este planteamiento es perfectamente funcional, el diseño no es adecua-
do ya que resulta bastante lógico pensar que ArbolNodo no es un tipo especial de
ReceptorMensajes. Si no es así, entonces no debería utilizarse herencia. Simple y lla-
namente. Del mismo modo, la relación inversa tampoco es lógica.
Desde un punto de vista general, la herencia múltiple puede introducir una serie
de complicaciones y desventajas, entre las que destacan las siguientes:
Ambigüedad, debido a que las clases base de las que hereda una clase derivada
pueden mantener el mismo nombre para una función. Para solucionar este pro-
blema, se puede explicitar el nombre de la clase base antes de hacer uso de la
función, es decir, ClaseBase::Funcion.
Topografía, debido a que se puede dar la situación en la que una clase derivada
herede de dos clases base, que a su vez heredan de otra clase, compartiendo
todas ellas la misma clase. Este tipo de árboles de herencia puede generar con-
secuencias inesperadas, como duplicidad de variables y ambigüedad. Este tipo
de problemas se puede solventar mediante herencia virtual, concepto distinto al
que se estudiará en el siguiente apartado relativo al uso de funciones virtuales.
Arquitectura del programa, debido a que el uso de la herencia, simple o múlti-
ple, puede contribuir a degradar el diseño del programa y crear un fuerte acopla-
miento entre las distintas clases que la componen. En general, es recomendable
utilizar alternativas como la composición y relegar el uso de la herencia múltiple
sólo cuando sea la mejor alternativa real.
C3
Listado 3.26: Manejo de punteros a clase base (cont.)
1 #include "Coche.cpp"
2
3 int main () {
4 Vehiculo *v; // Puntero a objeto de tipo vehículo.
5 Coche c; // Objeto de tipo coche.
6
7 c.setRuedas(4); // Se establece el estado de c.
8 c.setPasajeros(7);
9 c.setPMA(1885);
10
11 v = &c; // v apunta a un objeto de tipo coche.
12
13 cout << v->getPMA() << endl; // ERROR en tiempo de compilación.
14
15 cout << ((Coche*)v)->getPMA() << endl; // NO recomendable.
16
17 cout << static_cast<Coche*>(v)->getPMA() << endl; // Estilo C++.
18
19 return 0;
20 }
La palabra clave virtual Una función virtual es una función declarada como virtual en la clase base y rede-
finida en una o más clases derivadas. De este modo, cada clase derivada puede tener
Una clase que incluya una función su propia versión de dicha función. El aspecto interesante es lo que ocurre cuando se
virtual se denomina clase polimór-
fica. llama a esta función con un puntero o referencia a la clase base. En este contexto,
C++ determina en tiempo de ejecución qué versión de la función se ha de ejecutar en
función del tipo de objeto al que apunta el puntero.
Soy Base!
Soy Derivada1!
Soy Derivada2!
Las funciones virtuales han de ser miembros de la clase en la que se definen, Sobrecarga/sobreescrit.
es decir, no pueden ser funciones amigas. Sin embargo, una función virtual puede
ser amiga de otra clase. Además, los destructores se pueden definir como funciones Cuando una función virtual se rede-
fine en una clase derivada, la fun-
virtuales, mientras que en los constructores no es posible. ción se sobreescribe. Para sobrecar-
Las funciones virtuales se heredan de manera independiente del número de niveles gar una función, recuerde que el nú-
mero de parámetros y/o sus tipos
que tenga la jerarquía de clases. Suponga que en el ejemplo anterior Derivada2 hereda han de ser diferentes.
de Derivada1 en lugar de heredar de Base. En este caso, la función imprimir seguiría
siendo virtual y C++ sería capaz de seleccionar la versión adecuada al llamar a dicha
función. Si una clase derivada no sobreescribe una función virtual definida en la clase
base, entonces se utiliza la versión de la clase base.
El polimorfismo permite manejar la complejidad de los programas, garantizando
la escalabilidad de los mismos, debido a que se basa en el principio de una interfaz,
múltiples métodos. Por ejemplo, si un programa está bien diseñado, entonces se puede
suponer que todos los objetos que derivan de una clase base se acceden de la misma
forma, incluso si las acciones específicas varían de una clase derivada a la siguiente.
Esto implica que sólo es necesario recordar una interfaz. Sin embargo, la clase deri-
vada es libre de añadir uno o todos los aspectos funcionales especificados en la clase
base.
C3
Funciones virtuales puras y clases abstractas
33 }
El uso más relevante de las clases abstractas consiste en proporcionar una interfaz
sin revelar ningún aspecto de la implementación subyacente. Esta idea está fuertemen-
te relacionada con la encapsulación, otro de los conceptos fundamentales de la POO
junto con la herencia y el polimorfismo.
3.4. Plantillas
En el desarrollo de software es bastante común encontrarse con situaciones en las
que los programas implementados se parecen enormemente a otros implementados
con anterioridad, salvo por la necesidad de tratar con distintos tipos de datos o de
clases. Por ejemplo, un mismo algoritmo puede mantener el mismo comportamiento
de manera que éste no se ve afectado por el tipo de datos a manejar.
En esta sección se discute el uso de las plantillas en C++, un mecanismo que
permite escribir código genérico sin tener dependencias explícitas respecto a tipos de
datos específicos.
C3
<MiClase> <MiClase> <MiClase> <MiClase>
(a)
<MiClase> <MiClase>
MiClase
(b) (c)
Figura 3.3: Distintos enfoques para la implementación de una lista con elementos génericos. (a) Integración
en la propia clase de dominio. (b) Uso de herencia. (c) Contenedor con elementos de tipo nulo.
Otra posible solución consiste en hacer uso de la herencia para definir una clase
base que represente a cualquier elemento de una lista (ver figura 3.3.b). De este modo,
cualquier clase que desee incluir la funcionalidad asociada a la lista simplemente ha
de extenderla. Este planteamiento permite tratar a los elementos de la lista mediante
polimorfismo. Sin embargo, la mayor desventaja que presenta este enfoque está en el
diseño, ya que no es posible separar la funcionalidad de la lista de la clase propiamente
dicha, de manera que no es posible tener el objeto en múltiples listas o en alguna otra
estructura de datos. Por lo tanto, es importante separar la propia lista de los elementos
que realmente contiene.
Una alternativa para proporcionar esta separación consiste en hacer uso de una
lista que maneja punteros de tipo nulo para albergar distintos tipos de datos (ver fi-
gura 3.3.c). De este modo, y mediante los moldes correspondientes, es posible tener
una lista con elementos de distinto tipo y, al mismo tiempo, la funcionalidad de la mis-
ma está separada del contenido. La principal desventaja de esta aproximación es que
no es type-safe, es decir, depende del programador incluir la funcionalidad necesaria
para convertir tipos, ya que el compilador no los detectará.
Otra desventaja de esta propuesta es que son necesarias dos reservas de memoria
para cada uno de los nodos de la lista: una para el objeto y otra para el siguiente
nodo de la lista. Este tipo de cuestiones han de considerarse de manera especial en
el desarrollo de videojuegos, ya que la plataforma hardware final puede tener ciertas
restricciones de recursos.
[96] CAPÍTULO 3. C++. ASPECTOS ESENCIALES
La figura 3.3 muestra de manera gráfica las distintas opciones discutidas hasta
ahora en lo relativo a la implementación de una lista que permita el tratamiento de
datos genéricos.
C3
Las plantillas de funciones siguen la misma idea que las plantillas de clases apli-
cándolas a las funciones. Obviamente, la principal diferencia con respecto a las plan-
tillas a clases es que no necesitan instanciarse. El siguiente listado de código muestra
un ejemplo sencillo de la clásica función swap para intercambiar el contenido de dos
variables. Dicha función puede utilizarse con enteros, valores en punto flotante, ca-
denas o cualquier clase con un constructor de copia y un constructor de asignación.
Además, la función se instancia dependiendo del tipo de datos utilizado. Recuerde que
no es posible utilizar dos tipos de datos distintos, es decir, por ejemplo un entero y un
valor en punto flotante, ya que se producirá un error en tiempo de compilación.
Uso de plantillas El uso de plantillas en C++ solventa todas las necesidades planteadas para manejar
las listas introducidas en la sección 3.4.1, principalmente las siguientes:
Las plantillas son una herramienta
excelente para escribir código que
no dependa de un tipo de datos es- Flexibilidad, para poder utilizar las listas con distintos tipos de datos.
pecífico.
Simplicidad, para evitar la copia de código cada vez que se utilice una estruc-
tura de lista.
Uniformidad, ya que se maneja una única interfaz para la lista.
Independencia, entre el código asociado a la funcionalidad de la lista y el có-
digo asociado al tipo de datos que contendrá la lista.
7
8 private:
9 T _datos;
10 };
11
12 template<class T>
13 class Lista {
14 public:
15 NodoLista<T> getCabeza ();
16 void insertarFinal (T datos);
17 // Resto funcionalidad...
18
19 private:
20 NodoLista<T> *_cabeza;
21 };
Desde una perspectiva general, no debe olvidar que las plantillas representan una
herramienta adecuada para un determinado uso, por lo que su uso indiscriminado es un
error. Recuerde también que las plantillas introducen una dependencia de uso respecto
a otras clases y, por lo tanto, su diseño debería ser simple y mantenible.
Una de las situaciones en las que el uso de plantillas resulta adecuado está aso-
ciada al uso de contenedores, es decir, estructuras de datos que contienen objetos de
distintas clases. En este contexto, es importante destacar que la biblioteca STL de C++
ya proporciona una implementación de listas, además de otras estructuras de datos y
de algoritmos listos para utilizarse. Por lo tanto, es bastante probable que el desarro-
llador haga un uso directo de las mismas en lugar de tener que desarrollar desde cero
su propia implementación. En el capítulo 5 se estudia el uso de la biblioteca STL y se
discute su uso en el ámbito del desarrollo de videojuegos.
3.5. Manejo de excepciones [99]
C3
Freezing issues A la hora de afrontar cualquier desarrollo software, un programador siempre tiene
que tratar con los errores que dicho software puede generar. Existen diversas estra-
Aunque el desarrollo de videojue- tegias para afrontar este problema, desde simplemente ignorarlos hasta hacer uso de
gos comerciales madura año a año,
aún hoy en día es bastante común técnicas que los controlen de manera que sea posible recuperarse de los mismos. En
encontrar errores y bugs en los mis- esta sección se discute el manejo de excepciones en C++ con el objetivo de escribir
mos. Algunos de ellos obligan in- programas robustos que permitan gestionar de manera adecuada el tratamiento de
cluso a resetear la estación de jue- errores y situaciones inesperadas. Sin embargo, antes de profundizar en este aspecto
gos por completo.
se introducirán brevemente las distintas alternativas más relevantes a la hora de tratar
con errores.
Hasta ahora, los enfoques comentados tienen una serie de desventajas importantes.
En este contexto, las excepciones se posicionan como una alternativa más adecuada
y práctica. En esencia, el uso de excepciones permite que cuando un programa se
tope con una situación inesperada se arroje una excepción. Este hecho tiene como
consecuencia que el flujo de ejecución salte al bloque de captura de excepciones más
cercano. Si dicho bloque no existe en la función en la que se arrojó la excepción,
entonces el programa gestionará de manera adecuada la salida de dicha función (des-
truyendo los objetos vinculados) y saltará a la función padre con el objetivo de buscar
un bloque de captura de excepciones.
Este proceso se realiza recursivamente hasta encontrar dicho bloque o llegará a
la función principal delegando en el código de manejo de errores por defecto, que
típicamente finalizará la ejecución del programa y mostrará información por la salida
estándar o generará un fichero de log.
Los bloques de tratamiento de excepciones ofrecen al desarrollador la flexibili-
dad de hacer lo que desee con el error, ya sea ignorarlo, tratar de recuperarse del
mismo o simplemente informar sobre lo que ha ocurrido. Este planteamiento facili-
ta enormemente la distinción entre distintos tipos de errores y, consecuentemente, su
tratamiento.
C3
4
5 int main () {
6 try {
7 int *array = new int[1000000];
8 }
9 catch (bad_alloc &e) {
10 cerr << "Error al reservar memoria." << endl;
11 }
12
13 return 0;
14 }
MiExcepción
C3
desea que el programa capture cualquier tipo de excepción, entonces se puede añadir
una captura genérica (ver cuarto bloque catch). En resumen, si se lanza una excepción
no contemplada en un bloque catch, entonces el programa seguirá buscando el bloque
catch más cercano.
Es importante resaltar que el orden de las sentencias catch es relevante, ya que
dichas sentencias siempre se procesan de arriba a abajo. Además, cuando el programa
encuentra un bloque que trata con la excepción lanzada, el resto de bloques se ignoran
automáticamente.
Exception handlers Otro aspecto que permite C++ relativo al manejo de excepciones es la posibilidad
de re-lanzar una excepción, con el objetivo de delegar en una capa superior el trata-
El tratamiento de excepciones se
puede enfocar con un esquema pa- miento de la misma. El siguiente listado de código muestra un ejemplo en el que se
recido al del tratamiento de eventos, delega el tratamiento del error de entrada/salida.
es decir, mediante un planteamien-
to basado en capas y que delege las
excepciones para su posterior ges- Listado 3.36: Re-lanzando una excepción
tión.
1 void Mesh::cargar (const char *archivo) {
2
3 try {
4 Stream stream(archivo); // Puede generar un error de I/O.
5 cargar(stream);
6 }
7
8 catch (MiExcepcionIO &e) {
9 if (e.datosCorruptos()) {
10 // Tratar error I/O.
11 }
12 else {
13 throw; // Se re-lanza la excepción.
14 }
15 }
16
17 }
C3
se producirá el clásico memory leak, es decir, la situación en la que no se libera una
porción de memoria que fue previamente reservada. En estos casos, el destructor no se
ejecuta ya que, después de todo, el constructor no finalizó correctamente su ejecución.
La solución pasa por hacer uso de este tipo de punteros.
El caso de los destructores es menos problemático, ya que es lógico suponer
que nadie hará uso de un objeto que se va a destruir, incluso cuando se genere una
excepción dentro del propio destructor. Sin embargo, es importante recordar que el
destructor no puede lanzar una excepción si el mismo fue llamado como consecuencia
de otra excepción.
C
uando nos enfrentamos al diseño de un programa informático como un video-
juego, no es posible abordarlo por completo. El proceso de diseño de una
aplicación suele ser iterativo y en diferentes etapas de forma que se vaya refi-
nando con el tiempo. El diseño perfecto y a la primera es muy difícil de conseguir.
La tarea de diseñar aplicaciones es compleja y, con seguridad, una de las más
importantes y que más impacto tiene no sólo sobre el producto final, sino también
sobre su vida futura. En el diseño de la aplicación es donde se definen las estructuras
y entidades que se van a encargar de resolver el problema modelado, así como sus
relaciones y sus dependencias. Cómo de bien definamos estas entidades y relaciones
influirá, en gran medida, en el éxito o fracaso del proyecto y en la viabilidad de su
mantenimiento.
El diseño, por tanto, es una tarea capital en el ciclo de vida del software. Sin
embargo, no existe un procedimiento sistemático y claro sobre cómo crear el mejor
diseño para un problema dado. Podemos utilizar metodologías, técnicas y herramien-
tas que nos permitan refinar nuestro diseño. La experiencia también juega un papel
importante. Sin embargo, el contexto de la aplicación es crucial y los requisitos, que
pueden cambiar durante el proceso de desarrollo, también.
Un videojuego es un programa con una componente muy creativa. Además, el
mercado de videojuegos se mueve muy deprisa y la adaptación a ese medio “cam-
biante” es un factor determinante. En general, los diseños de los programas deben ser
escalables, extensibles y que permitan crear componentes reutilizables.
Esta última característica es muy importante ya que la experiencia nos hace ver que
al construir una aplicación se nos presentan situaciones recurrentes y que se asemejan
a situaciones pasadas. Es el deja vú en el diseño: «¿cómo solucioné esto?». En esencia,
muchos componentes pueden modelarse de formas similares.
107
[108] CAPÍTULO 4. PATRONES DE DISEÑO
En este capítulo, se describen algunos patrones de diseño que almacenan este co-
nocimiento experimental de diseño procedente del estudio de aplicaciones y de los
éxitos y fracasos de casos reales. Bien utilizados, permiten obtener un mejor diseño
más temprano.
4.1. Introducción
El diseño de una aplicación es un proceso iterativo y de continuo refinamiento.
Normalmente, una aplicación es lo suficientemente compleja como para que su di-
seño tenga que ser realizado por etapas, de forma que al principio se identifican los
módulos más abstractos y, progresivamente, se concreta cada módulo con un diseño
en particular.
En el camino, es común encontrar problemas y situaciones que conceptualmente
pueden parecerse entre sí, por lo menos a priori. Quizás un estudio más exhaustivo de
los requisitos permitan determinar si realmente se trata de problemas equivalentes.
Por ejemplo, supongamos que para resolver un determinado problema se llega
a la conclusión de que varios tipos de objetos deben esperar a un evento producido
por otro. Esta situación puede darse en la creación de una interfaz gráfica donde la
pulsación de un botón dispara la ejecución de otras acciones. Pero también es similar
a la implementación de un manejador del teclado, cuyas pulsaciones son recogidas por
los procesos interesados, o la de un gestor de colisiones, que notifica choques entre
elementos del juego. Incluso se parece a la forma en que muchos programas de chat
envían mensajes a un grupo de usuarios.
Ciertamente, cada uno de los ejemplos anteriores tiene su contexto y no es posible Definición
(ni a veces deseable) aplicar exactamente la misma solución a cada uno de ellos. Sin En [38], un patrón de diseño es una
embargo, sí que es cierto que existe semejanza en la esencia del problema. En nuestro descripción de la comunicación en-
ejemplo, en ambos casos existen entidades que necesitan ser notificadas cuando ocurre tre objetos y clases personalizadas
un cierto evento. para solucionar un problema gené-
rico de diseño bajo un contexto de-
Los patrones de diseño son formas bien conocidas y probadas de resolver proble- terminado.
mas de diseño que son recurrentes en el tiempo. Los patrones de diseño son amplia-
mente utilizados en las disciplinas creativas y técnicas. Así, de la misma forma que
un guionista de cine crea guiones a partir de patrones argumentales como «comedia»
o «ciencia-ficción», un ingeniero se basa en la experiencia de otros proyectos para
identificar patrones comunes que le ayuden a diseñar nuevos procesos. De esta for-
ma, reutilizando soluciones bien probadas y conocidas se ayuda a reducir el tiempo
necesario para el diseño.
Los patrones sintetizan la tradición y experiencia profesional de diseñadores de
software experimentados que han evaluado y demostrado que la solución proporciona-
da es una buena solución bajo un determinado contexto. El diseñador o desarrollador
que conozca diferentes patrones de diseño podrá reutilizar estas soluciones, pudiendo
alcanzar un mejor diseño más rápidamente.
4.1. Introducción [109]
C4
Cuando se describe un patrón de diseño se pueden citar más o menos propiedades
del mismo: el problema que resuelve, sus ventajas, si proporciona escalabilidad en el
diseño o no, etc. Nosotros vamos a seguir las directrices marcadas por los autores del
famoso libro de Design Patterns [38] 1 , por lo que para definir un patrón de diseño es
necesario describir, como mínimo, cuatro componentes fundamentales:
1 También conocido como The Gang of Four (GoF) (la «Banda de los Cuatro») en referencia a los autores
del mismo. Sin duda se trata de un famoso libro en este área, al que no le faltan detractores.
[110] CAPÍTULO 4. PATRONES DE DISEÑO
4.2.1. Singleton
El patrón singleton se suele utilizar cuando se requiere tener una única instancia
de un determinado tipo de objeto.
Problema
C4
En C++, utilizando el operador new es posible crear una instancia de un objeto.
Sin embargo, es posible que necesitemos que sólo exista una instancia de una clase
determinada por diferentes motivos (prevención de errores, seguridad, etc.).
El balón en un juego de fútbol o la entidad que representa al mundo 3D son ejem-
plos donde podría ser conveniente mantener una única instancia de este tipo de objetos.
Solución
Para garantizar que sólo existe una instancia de una clase es necesario que los
clientes no puedan acceder directamente al constructor. Por ello, en un singleton el
constructor es, por lo menos, protected. A cambio se debe proporcionar un único
punto (controlado) por el cual se pide la instancia única. El diagrama de clases de este
patrón se muestra en la figura 4.1.
Implementación
Como se puede ver, la característica más importante es que los métodos que pue-
den crear una instancia de Ball son todos privados para los clientes externos. Todos
ellos deben utilizar el método estático getTheBall() para obtener la única instan-
cia.
Consideraciones
Problema
En ella, se muestra jerarquías de clases que modelan los diferentes tipos de per-
sonajes de un juego y algunas de sus armas. Para construir cada tipo de personaje es
necesario saber cómo construirlo y con qué otro tipo de objetos tiene relación. Por
ejemplo, restricciones del tipo «la gente del pueblo no puede llevar armas» o «los ar-
queros sólo pueden puede tener un arco», es conocimiento específico de la clase que
se está construyendo.
Supongamos que en nuestro juego, queremos obtener razas de personajes: hom-
bres y orcos. Cada raza tiene una serie de características propias que hacen que pueda
moverse más rápido, trabajar más o tener más resistencia a los ataques.
4.2. Patrones de creación [113]
El patrón Abstract Factory puede ser de ayuda en este tipo de situaciones en las que
es necesario crear diferentes tipos de objetos utilizando una jerarquía de componentes.
C4
Dada la complejidad que puede llegar a tener la creación de una instancia es deseable
aislar la forma en que se construye cada clase de objeto.
Solución
En la figura 4.3 se muestra la aplicación del patrón para crear las diferentes ra-
zas de soldados. Por simplicidad, sólo se ha aplicado a esta parte de la jerarquía de
personajes.
En primer lugar se define una factoría abstracta que será la que utilice el cliente
(Game) para crear los diferentes objetos. CharFactory es una factoría que sólo
define métodos abstractos y que serán implementados por sus clases hijas. Éstas son
factorías concretas a cada tipo de raza (ManFactory y OrcFactory) y ellas son
las que crean las instancias concretas de objetos Archer y Rider para cada una de
las razas.
En definitiva, el patrón Abstract Factory recomienda crear las siguientes entidades:
Factoría abstracta que defina una interfaz para que los clientes puedan crear los
distintos tipos de objetos.
Factorías concretas que realmente crean las instancias finales.
Implementación
Nótese como las factorías concretas ocultan las particularidades de cada tipo. Una
implementación similar tendría el método makeRider().
Consideraciones
C4
cuando la creación de las instancias implican la imposición de restricciones y
otras particularidades propias de los objetos que se construyen.
los productos que se deben fabricar en las factorías no cambian excesivamente
en el tiempo. Añadir nuevos productos implica añadir métodos a todas las fac-
torías ya creadas, por lo que es un poco problemático. En nuestro ejemplo, si
quisiéramos añadir un nuevo tipo de soldado deberíamos modificar la factoría
abstracta y las concretas. Por ello, es recomendable que se aplique este patrón
sobre diseños con un cierto grado de estabilidad.
Un patrón muy similar a éste es el patrón Builder. Con una estructura similar,
el patrón Builder se centra en el proceso de cómo se crean las instancias y no en
la jerarquía de factorías que lo hacen posible. Como ejercicio se plantea estudiar el
patrón Builder y encontrar las diferencias.
Problema
Al igual que ocurre con el patrón Abstract Factory, el problema que se pretende
resolver es la creación de diferentes instancias de objetos abstrayendo la forma en que
realmente se crean.
Solución
La figura 4.4 muestra un diagrama de clases para nuestro ejemplo que emplea el
patrón Factory Method para crear ciudades en las que habitan personajes de diferentes
razas.
Como puede verse, los objetos de tipo Village tienen un método populate()
que es implementado por las subclases. Este método es el que crea las instancias de
Villager correspondientes a cada raza. Este método es el método factoría. Además
de este método, también se proporcionan otros como population() que devuelve
la población total, o location() que devuelve la posición de la cuidad en el mapa.
Todos estos métodos son comunes y heredados por las ciudades de hombres y orcos.
Finalmente, objetos Game podrían crear ciudades y, consecuentemente, crear ciu-
dadanos de distintos tipos de una forma transparente.
Consideraciones
Nótese que el patrón Factory Method se utiliza para implementar el patrón Abs-
tract Factory ya que la factoría abstracta define una interfaz con métodos de construc-
ción de objetos que son implementados por las subclases.
4.2.4. Prototype
El patrón Prototype proporciona abstracción a la hora de crear diferentes objetos
en un contexto donde se desconoce cuántos y cuáles deben ser creados a priori. La
idea principal es que los objetos deben poder clonarse en tiempo de ejecución.
Problema
Solución
C4
Para atender a las nuevas necesidades dinámicas en la creación de los distintos
tipo de armas, sin perder la abstracción sobre la creación misma, se puede utilizar el
patrón Prototype como se muestra en la figura 4.5.
Consideraciones
Puede parecer que entra en conflicto con Abstract Factory debido a que intenta
eliminar, precisamente, factorías intermedias. Sin embargo, es posible utilizar
ambas aproximaciones en una Prototype Abstract Factory de forma que la fac-
toría se configura con los prototipos concretos que puede crear y ésta sólo invoca
a clone().
También es posible utilizar un gestor de prototipos que permita cargar y descar-
gar los prototipos disponibles en tiempo de ejecución. Este gestor es interesante
para tener diseños ampliables en tiempo de ejecución (plugins).
Para que los objetos puedan devolver una copia de sí mismo es necesario que en
su implementación esté el constructor de copia (copy constructor) que en C++
viene por defecto implementado.
4.3.1. Composite
El patrón Composite se utiliza para crear una organización arbórea y homogénea
de instancias de objetos.
Problema
Solución
Por otro lado, hay objetos hoja que no contienen a más objetos, como es el caso
de Clock.
Consideraciones
Una buena estrategia para identificar la situación en la que aplicar este patrón
es cuando tengo «un X y tiene varios objetos X».
4.3. Patrones estructurales [119]
C4
prohibir la composición de un tipo de objeto con otro. Por ejemplo, un jarrón
grande dentro de una pequeña bolsa. La comprobación debe hacerse en tiempo
de ejecución y no es posible utilizar el sistema de tipos del compilador. En este
sentido, usando Composite se relajan las restricciones de composición entre
objetos.
Los usuarios de la jerarquía se hacen más sencillos, ya que sólo tratan con un
tipo abstracto de objeto, dándole homogeneidad a la forma en que se maneja la
estructura.
4.3.2. Decorator
También conocido como Wrapper, el patrón Decorator sirve para añadir y/o mo-
dificar la responsabilidad, funcionalidad o propiedades de un objeto en tiempo de
ejecución.
Problema
Supongamos que el personaje de nuestro videojuego porta un arma que utiliza para
eliminar a sus enemigos. Dicha arma, por ser de un tipo determinado, tiene una serie
de propiedades como el radio de acción, nivel de ruido, número de balas que puede
almacenar, etc. Sin embargo, es posible que el personaje incorpore elementos al arma
que puedan cambiar estas propiedades como un silenciador o un cargador extra.
El patrón Decorator permite organizar el diseño de forma que la incorporación
de nueva funcionalidad en tiempo de ejecución a un objeto sea transparente desde el
punto de vista del usuario de la clase decorada.
Solución
Implementación
8 public:
9 float noise () const { return 150.0; }
10 int bullets () const { return 5; }
11 };
12
13 /* Decorators */
14
15 class FirearmDecorator : public Firearm {
16 protected:
17 Firearm* _gun;
18 public:
19 FirearmDecorator(Firearm* gun): _gun(gun) {};
20 virtual float noise () const { return _gun->noise(); }
21 virtual int bullets () const { return _gun->bullets(); }
22 };
23
24 class Silencer : public FirearmDecorator {
25 public:
26 Silencer(Firearm* gun) : FirearmDecorator(gun) {};
27 float noise () const { return _gun->noise() - 55; }
28 int bullets () const { return _gun->bullets(); }
29 };
30
31 class Magazine : public FirearmDecorator {
32 public:
33 Magazine(Firearm* gun) : FirearmDecorator(gun) {};
34 float noise () const { return _gun->noise(); }
35 int bullets () const { return _gun->bullets() + 5; }
36 };
37
38 /* Using decorators */
39
40 ...
41 Firearm* gun = new Rifle();
42 cout << "Noise: " << gun->noise() << endl;
43 cout << "Bullets: " << gun->bullets() << endl;
44 ...
45 // char gets a silencer
46 gun = new Silencer(gun);
47 cout << "Noise: " << gun->noise() << endl;
48 cout << "Bullets: " << gun->bullets() << endl;
49 ...
50 // char gets a new magazine
51 gun = new Magazine(gun);
52 cout << "Noise: " << gun->noise() << endl;
53 cout << "Bullets: " << gun->bullets() << endl;
4.3. Patrones estructurales [121]
En cada momento, ¿qué valores se imprimen?. Supón que el personaje puede qui-
tar el silenciador. ¿Qué cambios habría que hacer en el código para «quitar» el deco-
C4
rador a la instancia?
Consideraciones
Este patrón permite tener una jerarquía de clases compuestas, formando una es-
tructura más dinámica y flexible que la herencia estática. El diseño equivalente
utilizando mecanismos de herencia debería considerar todos los posibles casos
en las clases hijas. En nuestro ejemplo, habría 4 clases: rifle, rifle con silencia-
dor, rifle con cargador extra y rifle con silenciador y cargador. Sin duda, este
esquema es muy poco flexible.
4.3.3. Facade
El patrón Facade eleva el nivel de abstracción de un determinado sistema para
ocultar ciertos detalles de implementación y hacer más sencillo su uso.
Problema
Solución
Como se puede ver, el usuario ya no tiene que conocer las relaciones que exis-
ten entre los diferentes módulos para crear este tipo de animaciones. Esto aumenta,
levemente, el nivel de abstracción y hace más sencillo su uso.
En definitiva, el uso del patrón Facade proporciona una estructura de diseño como
la mostrada en la figura 4.8.
Consideraciones
C4
Los sistemas deben ser independientes y portables.
Controlar el acceso y la forma en que se utiliza un sistema determinado.
Los clientes pueden seguir utilizando los subsistemas directamente, sin pasar
por la fachada, lo que da la flexibilidad de elegir entre una implementación de
bajo nivel o no.
Sin embargo, utilizando el patrón Facade es posible caer en los siguientes errores:
Crear clases con un tamaño desproporcionado. Las clases fachada pueden con-
tener demasiada funcionalidad si no se divide bien las responsabilidades y se
tiene claro el objetivo para el cual se creo la fachada. Para evitarlo, es necesario
ser crítico/a con el nivel de abstracción que se proporciona.
Obtener diseños poco flexibles y con mucha contención. A veces, es posible
crear fachadas que obliguen a los usuarios a un uso demasiado rígido de la fun-
cionalidad que proporciona y que puede hacer que sea más cómodo, a la larga,
utilizar los subsistemas directamente. Además, una fachada puede convertirse
en un único punto de fallo, sobre todo en sistemas distribuidos en red.
Exponer demasiados elementos y, en definitiva, no proporcionar un nivel de
abstracción adecuado.
4.3.4. MVC
El patrón MVC (Model View Controller) se utiliza para aislar el dominio de apli-
cación, es decir, la lógica, de la parte de presentación (interfaz de usuario).
Problema
Solución
En el patrón MVC, mostrado en la figura 4.9, existen tres entidades bien definidas:
Vista: se trata de la interfaz de usuario que interactúa con el usuario y recibe sus
órdenes (pulsar un botón, introducir texto, etc.). También recibe órdenes desde
el controlador, para mostrar información o realizar un cambio en la interfaz.
Figura 4.9: Estructura del patrón
MVC.
[124] CAPÍTULO 4. PATRONES DE DISEÑO
Consideraciones
4.3.5. Adapter
El patrón Adapter se utiliza para proporcionar una interfaz que, por un lado, cum-
pla con las demandas de los clientes y, por otra, haga compatible otra interfaz que, a
priori, no lo es.
Problema
Solución
C4
Usando el patrón Adapter es posible crear una nueva interfaz de acceso a un deter-
minado objeto, por lo que proporciona un mecanismo de adaptación entre las deman-
das del objeto cliente y el objeto servidor que proporciona la funcionalidad.
Consideraciones
Tener sistemas muy reutilizables puede hacer que sus interfaces no puedan ser
compatibles con una común. El patrón Adapter es una buena opción en este
caso.
Un mismo adaptador puede utilizarse con varios sistemas.
Otra versión del patrón es que la clase Adapter sea una subclase del sistema
adaptado. En este caso, la clase Adapter y la adaptada tienen una relación más
estrecha que si se realiza por composición.
Este patrón se parece mucho al Decorator. Sin embargo, difieren en que la finali-
dad de éste es proporcionar una interfaz completa del objeto adaptador, mientras
que el decorador puede centrarse sólo en una parte.
4.3.6. Proxy
El patrón Proxy proporciona mecanismos de abstracción y control para acceder a
un determinado objeto «simulando» que se trata del objeto real.
[126] CAPÍTULO 4. PATRONES DE DISEÑO
Problema
Muchos de los objetos de los que puede constar una aplicación pueden presentar
diferentes problemas a la hora de ser utilizados por clientes:
Coste computacional: es posible que un objeto, como una imagen, sea costoso
de manipular y cargar.
Acceso remoto: el acceso por red es una componente cada vez más común entre
las aplicaciones actuales. Para acceder a servidores remotos, los clientes deben
conocer las interioridades y pormenores de la red (sockets, protocolos, etc.).
Acceso seguro: es posible que muchos objetos necesiten diferentes privilegios
para poder ser utilizados. Por ejemplo, los clientes deben estar autorizados para
poder acceder a ciertos métodos.
Dobles de prueba: a la hora de diseñar y probar el código, puede ser útil uti-
lizar objetos dobles que reemplacen instancias reales que pueden hacer que las
pruebas sea pesadas y/o lentas.
Solución
Implementación
C4
8
9 ...
10 /* perform a hard file load */
11 ...
12 }
13
14 void display() {
15 ...
16 /* perform display operation */
17 ...
18 }
19 };
20
21 class ImageProxy : public Graphic {
22 private:
23 Image* _image;
24 public:
25 void display() {
26 if (not _image) {
27 _image = new Image();
28 _image.load();
29 }
30 _image->display();
31 }
32 };
33
34 /* Client */
35 ...
36 Graphic image = new ImageProxy();
37 image->display(); // loading and display
38 image->display(); // just display
39 image->display(); // just display
40 ...
Consideraciones
Existen muchos ejemplos donde se hace un uso intensivo del patrón proxy en
diferentes sistemas:
4.4.1. Observer
El patrón Observer se utiliza para definir relaciones 1 a n de forma que un objeto
pueda notificar y/o actualizar el estado de otros automáticamente.
Problema
Solución
El patrón Observer proporciona un diseño con poco acoplamiento entre los obser-
vadores y el objeto observado. Siguiendo la filosofía de publicación/suscripción, los
objetos observadores se deben registrar en el objeto observado, también conocido co-
mo subject. Así, cuando ocurra el evento oportuno, el subject recibirá una invocación
a través de notify() y será el encargado de «notificar» a todos los elementos suscri-
tos a él a través del método update(). Los observadores que reciben la invocación
pueden realizar las acciones pertinentes como consultar el estado del dominio para
obtener nuevos valores. En la figura 4.12 se muestra un esquema general del patrón
Observer.
Consideraciones
C4
Figura 4.13: Diagrama de secuencia de ejemplo utilizando un Observer
Los canales de eventos es un patrón más genérico que el Observer pero que sigue
respetando el modelo push/pull. Consiste en definir estructuras que permiten la comu-
nicación n a n a través de un medio de comunicación (canal) que se puede multiplexar
en diferentes temas (topics). Un objeto puede establecer un rol suscriptor de un tema
dentro de un canal y sólo recibir las notificaciones del mismo. Además, también pue-
de configurarse como publicador, por lo que podría enviar actualizaciones al mismo
canal.
[130] CAPÍTULO 4. PATRONES DE DISEÑO
4.4.2. State
El patrón State es útil para realizar transiciones de estado e implementar autómatas
respetando el principio de encapsulación.
Problema
Es muy común que en cualquier aplicación, incluído los videojuegos, existan es-
tructuras que pueden ser modeladas directamente como un autómata, es decir, una
colección de estados y unas transiciones dependientes de una entrada. En este caso, la
entrada pueden ser invocaciones y/o eventos recibidos.
Por ejemplo, los estados de un personaje de un videojuego podrían ser: de pie,
tumbado, andando y saltando. Dependiendo del estado en el que se encuentre y de la
invocación recibida, el siguiente estado será uno u otro. Por ejemplo, si está de pie y
recibe la orden de tumbarse, ésta se podrá realizar. Sin embargo, si ya está tumbado
no tiene sentido volver a tumbarse, por lo que debe permanecer en ese estado.
Solución
Por cada estado en el que puede encontrarse el personaje, se crea una clase que
hereda de la clase abstracta anterior, de forma que en cada una de ellas se implementen
los métodos que producen cambio de estado.
Por ejemplo, según el diagrama, en el estado «de pie» se puede recibir la orden
de caminar, tumbarse y saltar, pero no de levantarse. En caso de recibir esta última, se
ejecutará la implementación por defecto, es decir, no hacer nada.
En definitiva, la idea es que las clases que representan a los estados sean las encar-
gadas de cambiar el estado del personaje, de forma que los cambios de estados quedan
encapsulados y delegados al estado correspondiente.
4.4. Patrones de comportamiento [131]
Consideraciones
C4
Los componentes del diseño que se comporten como autómatas son buenos
candidatos a ser modelados con el patrón State. Una conexión TCP (Transport
Control Protocol) o un carrito en una tienda web son ejemplos de este tipo de
problemas.
Es posible que una entrada provoque una situación de error estando en un deter-
minado estado. Para ello, es posible utilizar las excepciones para notificar dicho
error.
Las clases que representan a los estados no deben mantener un estado intrín-
seco, es decir, no se debe hacer uso de variables que dependan de un contexto.
De esta forma, el estado puede compartirse entre varias instancias. La idea de
compartir un estado que no depende del contexto es la base fundamental del pa-
trón Flyweight, que sirve para las situaciones en las que crear muchas instancias
puede ser un problema de rendimiento.
4.4.3. Iterator
El patrón Iterator se utiliza para ofrecer una interfaz de acceso secuencial a una
determinada estructura ocultando la representación interna y la forma en que realmen-
te se accede.
Problema
Solución
Con ayuda del patrón Iterator es posible obtener acceso secuencial, desde el punto
de vista del usuario, a cualquier estructura de datos, independientemente de su imple-
mentación interna. En la figura 4.15 se muestra un diagrama de clases genérico del
patrón. Como puede verse, la estructura de datos es la encargada de crear el iterador
adecuado para ser accedida a través del método iterator(). Una vez que el cliente
ha obtenido el iterador, puede utilizar los métodos de acceso que ofrecen tales como
next() (para obtener el siguiente elemento) o isDone() para comprobar si no
existen más elementos.
[132] CAPÍTULO 4. PATRONES DE DISEÑO
Implementación
35 bool isDone() {
36 return _currentIndex > _list->length();
};
C4
37
38 };
39
40 /* client using iterator */
41
42 List list = new List();
43 ListIterator it = list.iterator();
44
45 for (Object ob = it.first(); not it.isDone(); it.next()) {
46 // do the loop using ’ob’
47 };
Consideraciones
Problema
Por ejemplo, supongamos que tenemos dos tipos de jugadores de juegos de mesa:
ajedrez y damas. En esencia, ambos juegan igual; lo que cambia son las reglas del
juego que, obviamente, condiciona su estrategia y su forma de jugar concreta. Sin
embargo, en ambos juegos, los jugadores mueven en su turno, esperan al rival y esto
se repite hasta que acaba la partida.
El patrón Template Method consiste extraer este comportamiento común en una
clase padre y definir en las clases hijas la funcionalidad concreta.
Solución
Siguiendo con el ejemplo de los jugadores de ajedrez y damas, la figura 4.16 mues-
tra una posible aplicación del patrón Template Method a modo de ejemplo. Nótese que
la clase GamePlayer es la que implementa el método play() que es el que invoca
a los otros métodos que son implementados por las clases hijas. Este método es el
método plantilla.
Cada tipo de jugador define los métodos en base a las reglas y heurísticas de su
juego. Por ejemplo, el método isOver() indica si el jugador ya no puede seguir
jugando porque se ha terminado el juego. En caso de las damas, el juego se acaba para
el jugador si se ha quedado sin fichas; mientras que en el caso ajedrez puede ocurrir
por jaque mate (además de otros motivos).
Consideraciones
4.4.5. Strategy
C4
El patrón Strategy se utiliza para encapsular el funcionamiento de una familia de
algoritmos, de forma que se pueda intercambiar su uso sin necesidad de modificar a
los clientes.
Problema
Solución
La idea es extraer los métodos que conforman el comportamiento que puede ser
intercambiado y encapsularlo en una familia de algoritmos. En este caso, el movi-
miento del jugador se extrae para formar una jerarquía de diferentes movimientos
(Movement). Todos ellos implementan el método move() que recibe un contexto
que incluye toda la información necesaria para llevar a cabo el algoritmo.
El siguiente fragmento de código indica cómo se usa este esquema por parte de
un cliente. Nótese que al configurarse cada jugador, ambos son del mismo tipo de
cara al cliente aunque ambos se comportarán de forma diferente al invocar al método
doBestMove().
Consideraciones
El patrón Strategy es una buena alternativa a realizar subclases en las entidades que
deben comportarse de forma diferente en función del algoritmo utilizado. Al extraer la
heurística a una familia de algoritmos externos, obtenemos los siguientes beneficios:
4.4.6. Reactor
El patrón Reactor es un patrón arquitectural para resolver el problema de cómo
atender peticiones concurrentes a través de señales y manejadores de señales.
Problema
Solución
En el patrón Reactor se definen una serie de actores con las siguientes responsabi-
lidades (véase figura 4.18):
Eventos: los eventos externos que puedan ocurrir sobre los recursos (Handles).
Normalmente su ocurrencia es asíncrona y siempre está relaciona a un recurso
C4
determinado.
Recursos (Handles): se refiere a los objetos sobre los que ocurren los eventos.
La pulsación de una tecla, la expiración de un temporizador o una conexión en-
trante en un socket son ejemplos de eventos que ocurren sobre ciertos recursos.
La representación de los recursos en sistemas tipo GNU/Linux es el descriptor
de fichero.
Manejadores de Eventos: Asociados a los recursos y a los eventos que se pro-
ducen en ellos, se encuentran los manejadores de eventos (EventHandler)
que reciben una invocación a través del método handle() con la información
del evento que se ha producido.
Reactor: se trata de la clase que encapsula todo el comportamiento relativo a
la desmultiplexación de los eventos en manejadores de eventos (dispatching).
Cuando ocurre un cierto evento, se busca los manejadores asociados y se les
invoca el método handle().
Nótese que aunque los eventos ocurran concurrentemente el Reactor serializa las
llamadas a los manejadores. Por lo tanto, la ejecución de los manejadores de eventos
ocurre de forma secuencial.
Consideraciones
4.4.7. Visitor
El patrón Visitor proporciona un mecanismo para realizar diferentes operaciones
sobre una jerarquía de objetos de forma que añadir nuevas operaciones no haga nece-
sario cambiar las clases de los objetos sobre los que se realizan las operaciones.
Problema
Solución
Implementación
C4
Figura 4.19: Diagrama de clases del patrón Visitor
Consideraciones
C4
Hasta el momento, se han descrito algunos de los patrones de diseño más impor-
tantes que proporcionan una buena solución a determinados problemas a nivel de dise-
ño. Todos ellos son aplicables a cualquier lenguaje de programación en mayo o menor
medida: algunos patrones de diseño son más o menos difíciles de implementar en un
lenguaje de programación determinado. Dependerá de las estructuras de abstracción
que éste proporcione.
Sin embargo, a nivel de lenguaje de programación, existen «patrones» que hacen
que un programador comprenda mejor dicho lenguaje y aplique soluciones mejores,
e incluso óptimas, a la hora de resolver un problema de codificación. Los expresiones
idiomáticas (o simplemente, idioms) son un conjunto de buenas soluciones de progra-
mación que permiten:
Al igual que los patrones, los idioms tienen un nombre asociado (además de sus
alias), la definición del problema que resuelven y bajo qué contexto, así como algunas
consideraciones (eficiencia, etc.). A continuación, se muestran algunos de los más
relevantes. En la sección de «Patrones de Diseño Avanzados» se exploran más de
ellos.
Para que no aparezcan sorpresas una clase no trivial debe tener como mínimo:
C4
1 class Vehicle
2 {
3 public:
4 virtual ~Vehicle();
5 virtual std::string name() = 0;
6 virtual void run() = 0;
7 };
8
9 class Car: public Vehicle
10 {
11 public:
12 virtual ~Car();
13 std::string name() { return "Car"; }
14 void run() { /* ... */ }
15 };
16
17 class Motorbike: public Vehicle
18 {
19 public:
20 virtual ~Motorbike();
21 std::string name() { return "Motorbike"; }
22 void run() { /* ... */ }
23 };
Este idiom muestra cómo crear una clase que se comporta como una interfaz,
utilizando métodosvirtuales puros.
El compilador fuerza que los métodos virtuales puros sean implementados por
las clases derivadas, por lo que fallará en tiempo de compilación si hay alguna que
no lo hace. Como resultado, tenemos que Vehicle actúa como una clase interfaz.
Nótese que el destructor se ha declarado como virtual. Como ya se citó en la forma
canónica ortodoxa (sección 4.5.1), esto es una buena práctica para evitar posibles leaks
de memoria en tiempo de destrucción de un objeto Vehicle usado polimórficamente
(por ejemplo, en un contenedor). Con esto, se consigue que se llame al destructor de
la clase más derivada.
Desarrollo de una librería o una API que va ser utilizada por terceros.
Clases externas de módulos internos de un programa de tamaño medio o grande.
4.5.4. pImpl
Pointer To Implementation (pImpl), también conocido como Handle Body u Opa-
que Pointer, es un famoso idiom (utilizado en otros muchos) para ocultar la implemen-
tación de una clase en C++. Este mecanismo puede ser muy útil sobre todo cuando se
tienen componentes reutilizables cuya declaración o interfaz puede cambiar, lo cual
implicaría recompilar a todos sus usuarios. El objetivo es minimizar el impacto de un
cambio en la declaración de la clase a sus usuarios.
En C++, un cambio en las variables miembro de una clase o en los métodos
inline puede suponer que los usuarios de dicha clase tengan que recompilar. Para
resolverlo, la idea de pImpl es que la clase ofrezca una interfaz pública bien definida
y que ésta contenga un puntero a su implementación, descrita de forma privada.
Por ejemplo, la clase Vehicle podría ser de la siguiente forma:
Como se puede ver, es una clase muy sencilla ya que ofrece sólo un método públi-
co. Sin embargo, si queremos modificarla añadiendo más atributos o nuevos métodos
privados, se obligará a los usuarios de la clase a recompilar por algo que realmente no
utilizan directamente. Usando pImpl, quedaría el siguiente esquema:
C4
9 class VehicleImpl;
10 VehicleImpl* _pimpl;
11 };
capitalEn este capítulo se proporciona una visión general de STL (Standard Tem-
plate Library), la biblioteca estándar proporcionada por C++, en el contexto del desa-
rrollo de videojuegos, discutiendo su utilización en dicho ámbito de programación.
Asimismo, también se realiza un recorrido exhaustivo por los principales tipos de
contenedores, estudiando aspectos relevantes de su implementación, rendimiento y
uso en memoria. El objetivo principal que se pretende alcanzar es que el lector sea ca-
paz de utilizar la estructura de datos más adecuada para solucionar un problema,
justificando el porqué y el impacto que tiene dicha decisión sobre el proyecto en su
conjunto.
Usando STL Desde un punto de vista abstracto, la biblioteca estándar de C++, STL, es un
STL es sin duda una de las bibliote-
conjunto de clases que proporciona la siguiente funcionalidad [94]:
cas más utilizadas en el desarrollo
de aplicaciones de C++. Además,
está muy optimizada para el mane-
Soporte a características del lenguaje, como por ejemplo la gestión de memoria
jo de estructuras de datos y de algo- e información relativa a los tipos de datos manejados en tiempo de ejecución.
ritmos básicos, aunque su comple-
jidad es elevada y el código fuente Soporte relativo a aspectos del lenguaje definidos por la implementación, como
es poco legible para desarrolladores por ejemplo el valor en punto flotante con mayor precisión.
poco experimentados.
Soporte para funciones que no se pueden implementar de manera óptima con
las herramientas del propio lenguaje, como por ejemplo ciertas funciones mate-
máticas o asignación de memoria dinámica.
147
[148] CAPÍTULO 5. LA BIBLIOTECA STL
La figura 5.2 muestra una perspectiva global de la organización de STL y los di-
ferentes elementos que la definen. Como se puede apreciar, STL proporciona una gran
variedad de elementos que se pueden utilizar como herramientas para la resolución de
problemas dependientes de un dominio en particular.
Las utilidades de la biblioteca estándar se definen en el espacio de nombres std y
se encuentran a su vez en una serie de bibliotecas, que identifican las partes fundamen-
tales de STL. Note que no se permite la modificación de la biblioteca estándar y no es
aceptable modificar su contenido mediante macros, ya que afectaría a la portabilidad
del código desarrollado.
En el ámbito del desarrollo de videojuegos, los contenedores juegan un papel fun- Abstracción STL
damental como herramienta para el almacenamiento de información en memoria. En El uso de los iteradores en STL re-
este contexto, la realización un estudio de los mismos, en términos de operaciones, presenta un mecanismo de abstrac-
gestión de memoria y rendimiento, es especialmente importante para utilizarlos ade- ción fundamental para realizar el re-
cuadamente. Los contenedores están representados por dos tipos principales. Por una corrido, el acceso y la modificación
sobre los distintos elementos alma-
parte, las secuencias permiten almacenar elementos en un determinado orden. Por otra cenados en un contenedor.
parte, los contenedores asociativos no tienen vinculado ningún tipo de restricción de
orden.
La herramienta para recorrer los contenedores está representada por el iterador.
Todos los contenedores mantienen dos funciones relevantes que permiten obtener dos
iteradores:
Iteradores Algoritmos
C5
<iterator> Iteradores y soporte a iteradores
<algorithm> algoritmos generales
<cstdlib> bsearch() y qsort()
Cadenas
Figura 5.2: Visión general de la organización de STL [94]. El asterisco referencia a elementos con el estilo
del lenguaje C.
[150] CAPÍTULO 5. LA BIBLIOTECA STL
✄
en concreto la función push_back para añadir un elemento al final del vector. tas estructuras de datos incluidas en
Finalmente, en la línea ✂13 ✁se declara un iterador de tipo vector<int> que se utili-
la biblioteca estándar.
zará como base✄ para el recorrido del vector. La inicialización del mismo se encuentra
en la línea ✂15 ✁, es decir, en la parte de inicialización del bucle for, de manera que
originalmente el iterador apunta al primer elemento del vector. La iteración sobre el
✄vector
se estará realizando hasta que no se llegue al iterador devuelto por end() (línea
✂16 ✁), es decir, al elemento posterior al último.
✄
(línea ✂17 ✁), al igual que el acceso
Note cómo el incremento del iterador es ✄trivial
del contenido al que apunta el iterador (línea ✂18 ✁), mediante una nomenclatura similar
a la utilizada para manejar punteros.
Por otra parte, STL proporciona un conjunto estándar de algoritmos que se pueden
aplicar a los contenedores e iteradores. Aunque dichos algoritmos son básicos, éstos se
pueden combinar para obtener el resultado deseado por el propio programador. Algu-
nos ejemplos de algoritmos son la búsqueda de elementos, la copia, la ordenación, etc.
Al igual que ocurre con todo el código de STL, los algoritmos están muy optimizados
y suelen sacrificar la simplicidad para obtener mejores resultados que proporcionen
una mayor eficiencia.
5.2. STL y el desarrollo de videojuegos [151]
C5
desarrollo, herramientas transversales y middlewares con el objetivo de dar soporte al
proceso de desarrollo de un videojuego (ver sección 1.2.2). STL estaría incluido en
dicha capa como biblioteca estándar de C++, posibilitando el uso de diversos conte-
nedores como los mencionados anteriormente.
Desde un punto de vista general, a la hora de abordar el desarrollo de un videojue-
go, el programador o ingeniero tendría que decidir si utilizar STL o, por el contrario,
utilizar alguna otra biblioteca que se adapte mejor a los requisitos impuestos por el
juego a implementar.
5.2.2. Rendimiento
Uno de los aspectos críticos en el ámbito del desarrollo de videojuegos es el ren-
dimiento, ya que es fundamental para lograr un sensación de interactividad y para
dotar de sensación de realismo el usuario de videojuegos. Recuerde que un videojue-
go es una aplicación gráfica de renderizado en tiempo real y, por lo tanto, es necesario
asegurar una tasa de frames por segundo adecuada y en todo momento. Para ello, el
rendimiento de la aplicación es crítico y éste viene determinado en gran medida por
las herramientas utilizadas.
En general, STL proporciona un muy buen rendimiento debido principalmente a
que ha sido mejorado y optimizado por cientos de desarrolladores en estos últimos
años, considerando las propiedades intrínsecas de cada uno de sus elementos y de las
plataformas sobre las que se ejecutará.
STL será normalmente más eficiente que otra implementación y, por lo tanto, es La herramienta ideal
el candidato ideal para manejar las estructuras de datos de un videojuego. Mejorar el Benjamin Franklin afirmó que si
rendimiento de STL implica tener un conocimiento muy profundo de las estructuras dispusiera de 8 horas para derribar
de datos a manejar y puede ser una alternativa en casos extremos y con plataformas un árbol, emplearía 6 horas para afi-
hardware específicas. Si éste no es el caso, entonces STL es la alternativa directa. lar su hacha. Esta reflexión se puede
aplicar perfectamente a la hora de
No obstante, algunas compañías tan relevantes en el ámbito del desarrollo de vi- decidir qué estructura de datos uti-
deojuegos, como EA (Electronic Arts), han liberado su propia adaptación de STL de- lizar para solventar un problema.
nominada EASTL (Electronic Arts Standard Template Library) 1 , justificando esta
decisión en base a la detección de ciertas debilidades, como el modelo de asignación
de memoria, o el hecho de garantizar la consistencia desde el punto de vista de la
portabilidad.
Además del rendimiento de STL, es importante considerar que su uso permite
que el desarrollador se centre principalmente en el manejo de elementos propios, es
decir, a nivel de nodos en una lista o claves en un diccionario, en lugar de prestar más
importancia a elementos de más bajo nivel, como punteros o buffers de memoria.
Uno de los aspectos claves a la hora de manejar de manera eficiente STL se basa en
utilizar la herramienta adecuada para un problema concreto. Si no es así, entonces es
fácil reducir enormemente el rendimiento de la aplicación debido a que no se utilizó,
por ejemplo, la estructura de datos más adecuada.
5.2.3. Inconvenientes
El uso de STL también puede presentar ciertos inconvenientes; algunos de ellos
directamente vinculados a su propia complejidad [27]. En este contexto, uno de los
principales inconvenientes al utilizar STL es la depuración de programas, debido a
aspectos como el uso extensivo de plantillas por parte de STL. Este planteamiento
complica bastante la inclusión de puntos de ruptura y la depuración interactiva. Sin
embargo, este problema no es tan relevante si se supone que STL ha sido extensamente
probado y depurado, por lo que en teoría no sería necesario llegar a dicho nivel.
Por otra parte, visualizar de manera directa el contenido de los contenedores o
estructuras de datos utilizadas puede resultar complejo. A veces, es muy complicado
conocer exactamente a qué elemento está apuntando un iterador o simplemente ver
todos los elementos de un vector.
La última desventaja es la asignación de memoria, debido a que se pretende que
STL se utilice en cualquier entorno de cómputo de propósito general. En este contexto,
es lógico suponer que dicho entorno tenga suficiente memoria y la penalización por
asignar memoria no es crítica. Aunque esta situación es la más común a la hora de
1 http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2271.html
5.3. Secuencias [153]
C5
En general, hacer uso de STL para desarrollar un videojuego es una de las
mejores alternativas posibles. En el ámbito comercial, un gran número de
juegos de ordenador y de consola han hecho uso de STL. Recuerde que siem-
pre es posible personalizar algunos aspectos de STL, como la asignación de
memoria, en función de las restricciones existentes.
Tamaño elemento La principal característica de los contenedores de secuencia es que los elementos
almacenados mantienen un orden determinado. La inserción y eliminación de ele-
Inicio mentos se puede realizar en cualquier posición debido a que los elementos residen
en una secuencia concreta. A continuación se lleva a cabo una discusión de tres de
los contenedores de secuencia más utilizados: el vector (vector), la cola de doble fin
(deque) y la lista (list).
Implementación
Rendimiento
C5
y libera el bloque de memoria inicial. Este planteamiento evita que el vector tenga
que reasignar memoria con frecuencia. En este contexto, es importante resaltar que un
vector no garantiza la validez de un puntero o un iterador después de una inserción, ya
que se podría dar esta situación. Por lo tanto, si es necesario acceder a un elemento en
concreto, la solución ideal pasa por utilizar el índice junto con el operador [].
Vectores y reserve() Los vectores permiten la preasignación de un número de entradas con el objetivo
de evitar la reasignación de memoria y la copia de elementos a un nuevo bloque.
La reserva de memoria explícita Para ello, se puede utilizar la operación reserve de vector que permite especificar la
puede contribuir a mejorar el rendi-
miento de los vectores y evitar un cantidad de memoria reservada para el vector y sus futuros elementos.
alto número de operaciones de re-
serva.
El caso contrario a esta situación está representado por vectores que contienen
datos que se usan durante un cálculo en concreto pero que después se descartan. Si
esta situación se repite de manera continuada, entonces se está asignando y liberando
el vector constantemente. Una posible solución consiste en mantener el vector como
estático y limpiar todos los elementos después de utilizarlos. Este planteamiento ha-
ce que el vector quede vacío y se llame al destructor de cada uno de los elementos
previamente contenidos.
Finalmente, es posible aprovecharse del hecho de que los elementos de un vector
se almacenan de manera contigua en memoria. Por ejemplo, sería posible pasar el
contenido de un vector a una función que acepte un array, siempre y cuando dicha
función no modifique su contenido [27], ya que el contenido del vector no estaría
sincronizado.
[156] CAPÍTULO 5. LA BIBLIOTECA STL
5.3.2. Deque
Deque proviene del término inglés double-ended queue y representa una cola de
doble fin. Este tipo de contenedor es muy parecido al vector, ya que proporciona
acceso directo a cualquier elemento y permite la inserción y eliminación en cualquier
posición, aunque con distinto impacto en el rendimiento. La principal diferencia reside
en que tanto la inserción como eliminación del primer y último elemento de la cola
son muy rápidas. En concreto, tienen una complejidad constante, es decir, O(1).
La colas de doble fin incluyen la funcionalidad básica de los vectores pero tam- Deque y punteros
bién considera operaciones para insertar y eliminar elementos, de manera explícita, al
principio del contenedor3 . La cola de doble fin no garanti-
za que todos los elementos almace-
nados residan en direcciones conti-
guas de memoria. Por lo tanto, no es
Implementación posible realizar un acceso seguro a
los mismos mediante aritmética de
punteros.
Este contenedor de secuencia, a diferencia de los vectores, mantiene varios blo-
ques de memoria, en lugar de uno solo, de forma que se reservan nuevos bloques
conforme el contenedor va creciendo en tamaño. Al contrario que ocurría con los vec-
tores, no es necesario hacer copias de datos cuando se reserva un nuevo bloque de
memoria, ya que los reservados anteriormente siguen siendo utilizados.
La cabecera de la cola de doble fin almacena una serie de punteros a cada uno de
los bloques de memoria, por lo que su tamaño aumentará si se añaden nuevos bloques
que contengan nuevos elementos.
3 http://www.cplusplus.com/reference/stl/deque/
5.3. Secuencias [157]
Cabecera Bloque 1
Principio
Vacío
C5
Final
Elemento 1
#elementos
Elemento 2
Tamaño elemento
Bloque 1 Bloque 2
Bloque 2 Elemento 3
Bloque n Elemento 4
Elemento 5
Vacío
Bloque n Elemento 6
Elemento j
Elemento k
Vacío
Rendimiento
5.3.3. List
La lista es otro contenedor de secuencia que difiere de los dos anteriores. En pri-
mer lugar, la lista proporciona iteradores bidireccionales, es decir, iteradores que
permiten navegar en los dos sentidos posibles: hacia adelante y hacia atrás. En se-
gundo lugar, la lista no proporciona un acceso aleatorio a los elementos que contiene,
como sí hacen tanto los vectores como las colas de doble fin. Por lo tanto, cualquier
algoritmo que haga uso de este tipo de acceso no se puede aplicar directamente sobre
listas.
La principal ventaja de la lista es el rendimiento y la conveniencia para determina-
das operaciones, suponiendo que se dispone del iterador necesario para apuntar a una
determinada posición.
La especificación de listas de STL ofrece una funcionalidad básica muy similar Algoritmos en listas
a la de cola de doble fin, pero también incluye funcionalidad relativa a aspectos más
complejos como la fusión de listas o la búsqueda de elementos4 . La propia naturaleza de la lista per-
mite que se pueda utilizar con un
buen rendimiento para implementar
algoritmos o utilizar los ya existen-
Implementación tes en la propia biblioteca.
Rendimiento
Cabecera
Principio Siguiente Siguiente Siguiente
C5
Final Anterior Anterior Anterior
#elementos
Elemento 1 Elemento 2 Elemento 3
Tamaño elemento
...
El planteamiento basado en manejar punteros hace que las listas sean más eficien-
tes a la hora de, por ejemplo, reordenar sus elementos, ya que no es necesario realizar
copias de datos. En su lugar, simplemente hay que modificar el valor de los punteros
de acuerdo al resultado esperado o planteado por un determinado algoritmo. En este
contexto, a mayor número de elementos en una lista, mayor beneficio en comparación
con otro tipo de contenedores.
El anterior listado de código mostrará el siguiente resultado por la salida estándar:
0: 23 1: 28 2: 10 3: 17 4: 20 5: 22 6: 11
2: 10 6: 11 3: 17 4: 20 5: 22 0: 23 1: 28
C5
Recorrido Rápido Rápido Menos rápido
Asignación memoria Raramente Periódicamente Con cada I/E
Acceso memoria sec. Sí Casi siempre No
Invalidación iterador Tras I/E Tras I/E Nunca
Cuadro 5.1: Resumen de las principales propiedades de los contenedores de secuencia previamente estu-
diados (I/E = inserción/eliminación).
7 9 5
Cabecera
Principio
C5
Final
Tamaño elemento
...
Hijoi Elemento Hijodi Hijodi Elemento Hijod
Implementación
Rendimiento
Implementación
Rendimiento
C5
modo, es posible obtener cierta ventaja de manejar claves simples sobre una gran
cantidad de elementos complejos, mejorando el rendimiento de la aplicación. Sin em-
bargo, nunca hay que olvidar que si se manejan claves más complejas como cadenas
o tipos de datos definidos por el usuario, el rendimiento puede verse afectado negati-
vamente.
El operador [] Por otra parte, el uso del operador [] no tiene el mismo rendimiento que en vecto-
res, ya que implica realizar la búsqueda del elemento a partir de la clave y, por lo tanto,
El operador [] se puede utilizar pa-
ra acceder, escribir o actualizar in- no sería de un orden de complejidad constante sino logarítmico. Al usar este operador,
formación sobre algunos contene- también hay que tener en cuenta que si se escribe sobre un elemento que no existe,
dores. Sin embargo, es importante entonces éste se añade al contenedor. Sin embargo, si se intenta leer un elemento que
considerar el impacto en el rendi- no existe, entonces el resultado es que el elemento por defecto se añade para la clave
miento al utilizar dicho operador, el
cual vendrá determinado por el con- en concreto y, al mismo tiempo, se devuelve dicho valor. El siguiente listado de código
tenedor usado. muestra un ejemplo.
Como se puede apreciar, el listado de código muestra características propias de
STL para manejar los elementos del contenedor:
✄
En la línea ✂7 ✁se hace uso de pair para✄declarar
una pareja de valores: i) un itera-
dor al contenedor definido en la línea ✂6 ✁y ii) un valor booleano. Esta
✄ estructura
se utilizará para recoger el valor de retorno de una inserción (línea ✂14 ✁).
✄
Las líneas ✂10-11 ✁muestran inserciones de elementos.
✄
En la línea ✂15 ✁se recoge el valor de retorno al intentar insertar un elemento
✄
con una clave ya existente. Note cómo se accede al iterador en la línea ✂17 ✁para
obtener el valor previamente almacenado en la entrada con clave 2.
✄
La línea ✂20 ✁muestra un ejemplo de inserción con el operador [], ya que la clave
3 no se había utilizado previamente.
✄
La línea ✂23 ✁ejemplifica la inserción de un elemento por defecto. Al tratarse de
cadenas de texto, el valor por defecto es la cadena vacía.
Cuadro 5.2: Resumen de las principales propiedades de los contenedores asociativos previamente estudia-
dos (I/E = inserción/eliminación).
Ante esta situación, resulta deseable hacer uso de la operación find para determi- Usando referencias
nar si un elemento pertenece o no al contenedor, accediendo al mismo con el iterador Recuerde consultar información so-
devuelto por dicha operación. Así mismo, la inserción de elementos es más eficiente bre las operaciones de cada uno
con insert en lugar de con el operador [], debido a que este último implica un ma- de los contenedores estudiados para
yor número de copias del elemento a insertar. Por el contrario, [] es ligeramente más conocer exactamente su signatura y
cómo invocarlas de manera eficien-
eficiente a la hora de actualizar los contenidos del contenedor en lugar de insert. te.
Respecto al uso de memoria, los mapas tienen un comportamiento idéntico al de
los conjuntos, por lo que se puede suponer los mismos criterios de aplicación.
C5
desarrollador tenga que especificarla. Este enfoque se basa en que, por ejemplo, los
contenederos de secuencia existentes tienen la flexibilidad necesaria para comportar-
se como otras estructuras de datos bien conocidas, como por ejemplo las pilas o las
colas.
LIFO Un pila es una estructura de datos que solamente permite añadir y eliminar ele-
La pila está diseña para operar en un mentos por un extremo, la cima. En este contexto, cualquier conteneder de secuencia
contexto LIFO (Last In, First Out) discutido anteriormente se puede utilizar para proporcionar dicha funcionalidad. En
(last-in first-out), es decir, el ele- el caso particular de STL, es posible manejar pilas e incluso especificar la imple-
mento que se apiló más reciente- mentación subyacente que se utilizará, especificando de manera explícita cuál será el
mente será el primero en salir de la
pila. contenedor de secuencia usado.
Aunque sería perfectamente posible delegar en el programador el manejo del con-
tenedor de secuencia para que se comporte como, por ejemplo, una pila, en la práctica
existen dos razones importantes para la definición de adaptadores:
1. La declaración explícita de un adaptador, como una pila o stack, hace que sea el
Cima código sea mucho más claro y legible en términos de funcionalidad. Por ejem-
plo, declarar una pila proporciona más información que declarar un vector que
se comporte como una pila. Esta aproximación facilita la interoperabilidad con
otros programadores.
2. El compilador tiene más información sobre la estructura de datos y, por lo tanto,
puede contribuir a la detección de errores con más eficacia. Si se utiliza un vec-
tor para modelar una pila, es posible utilizar alguna operación que no pertenezca
Elemento 1 a la interfaz de la pila.
5.5.1. Stack
Elemento 2
El adaptador más sencillo en STL es la pila o stack9 . Las principales operaciones
sobre una pila son la inserción y eliminación de elementos por uno de sus extremos:
Elemento 3 la cima. En la literatura, estas operaciones se conocen como push y pop, respectiva-
mente.
... La pila es más restrictiva en términos funcionales que los distintos contenedores de
secuencia previamente estudiados y, por lo tanto, se puede implementar con cualquiera
de ellos. Obviamente, el rendimiento de las distintas versiones vendrá determinado por
el contenedor elegido. Por defecto, la pila se implementa utilizando una cola de doble
Elemento i fin.
El siguiente listado de código muestra cómo hacer uso de algunas de las operacio-
... nes más relevantes de la pila para invertir el contenido de un vector.
8 vector<int>::iterator it;
9 stack<int> pila;
10
11 for (int i = 0; i < 10; i++) // Rellenar el vector
12 fichas.push_back(i);
13
14 for (it = fichas.begin(); it != fichas.end(); ++it)
15 pila.push(*it); // Apilar elementos para invertir
16
17 fichas.clear(); // Limpiar el vector
18
19 while (!pila.empty()) { // Rellenar el vector
20 fichas.push_back(pila.top());
21 pila.pop();
22 }
23
24 return 0;
25 }
5.5.2. Queue
Al igual que ocurre con la pila, la cola o queue10 es otra estructura de datos de
uso muy común que está representada en STL mediante un adaptador de secuencia.
Básicamente, la cola mantiene una interfaz que permite la inserción de elementos al Inserción
final y la extracción de elementos sólo por el principio. En este contexto, no es posible
acceder, insertar y eliminar elementos que estén en otra posición.
Por defecto, la cola utiliza la implementación de la cola de doble fin, siendo posible
utilizar la implementación de la lista. Sin embargo, no es posible utilizar el vector
debido a que este contenedor no proporciona la operación push_front, requerida por
el propio adaptador. De cualquier modo, el rendimiento de un vector para eliminar
elementos al principio no es particularmente bueno, ya que tiene una complejidad Elemento 1
lineal.
Elemento 2
5.5.3. Cola de prioridad Elemento 3
STL también proporciona el adaptador cola de prioridad o priority queue11 como ...
caso especial de cola previamente discutido. La diferencia entre los dos adaptadores
reside en que el elemento listo para ser eliminado de la cola es aquél que tiene una
mayor prioridad, no el elemento que se añadió en primer lugar.
Elemento i
Así mismo, la cola con prioridad incluye cierta funcionalidad específica, a diferen-
cia del resto de adaptadores estudiados. Básicamente, cuando un elemento se añade a ...
la cola, entonces éste se ordena de acuerdo a una función de prioridad.
Por defecto, la cola con prioridad se apoya en la implementación de vector, pero es Elemento n
posible utilizarla también con deque. Sin embargo, no se puede utilizar una lista debi-
do a que la cola con prioridad requiere un acceso aleatorio para insertar eficientemente
elementos ordenados.
La comparación de elementos sigue el mismo esquema que el estudiado en la sec-
ción 5.4.1 y que permitía definir el criterio de inclusión de elementos en un conjunto,
el cual estaba basado en el uso del operador menor que.
Extracción
10 http://www.cplusplus.com/reference/stl/queue/
11 http://www.cplusplus.com/reference/stl/priority_queue/ Figura 5.12: Visión abstracta de
una cola o queue. Los elementos so-
lamente se añaden por el final y se
eliminan por el principio.
5.5. Adaptadores de secuencia [169]
C5
Gestión de Recursos
Capítulo 6
David Vallejo Fernández
E
n este capítulo se cubren aspectos esenciales a la hora de afrontar el diseño
de un juego. En particular, se discutirán las arquitecturas típicas del bucle de
juego, haciendo especial hincapié en un esquema basado en la gestión de los
estados de un juego. Como caso de estudio concreto, en este capítulo se propone una
posible implementación del bucle principal mediante este esquema haciendo uso de
las bibliotecas que Ogre3D proporciona.
Así mismo, este capítulo discute la gestión de recursos y, en concreto, la gestión
de sonido y de efectos especiales. La gestión de recursos es especialmente importante
en el ámbito del desarrollo de videojuegos, ya que el rendimiento de un juego depende
en gran medida de la eficiencia del subsistema de gestión de recursos.
Finalmente, la gestión del sistema de archivos, junto con aspectos básicos de en-
trada/salida, también se estudia en este capítulo. Además, se plantea el diseño e imple-
mentación de un importador de datos que se puede utilizar para integrar información
multimedia a nivel de código fuente.
171
[172] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
De cualquier modo, es necesario un planteamiento que permita actualizar el es-
tado de cada uno de los subsistemas y que considere las restricciones temporales de
los mismos. Típicamente, este planteamiento se suele abordar mediante el bucle de
juego, cuya principal responsabilidad consiste en actualizar el estado de los distintos
componentes del motor tanto desde el punto de vista interno (ej. coordinación en-
tre subsistemas) como desde el punto de vista externo (ej. tratamiento de eventos de
teclado o ratón).
Antes de discutir algunas de las arquitecturas más utilizadas para modelar el bucle
de juego, resulta interesante estudiar el anterior listado de código, el cual muestra una
manera muy simple de gestionar el bucle de juego a través de una sencilla estructura de
control iterativa. Evidentemente, la complejidad actual de los videojuegos comerciales
requiere un esquema que sea más general y escalable. Sin embargo, es muy importante
mantener la simplicidad del mismo para garantizar su mantenibilidad.
En las plataformas WindowsTM , los juegos han de atender los mensajes recibidos
por el propio sistema operativo y dar soporte a los distintos componentes del propio
motor de juego. Típicamente, en estas plataformas se implementan los denominados
message pumps [42], como responsables del tratamiento de este tipo de mensajes.
Desde un punto de vista general, el planteamiento de este esquema consiste en
atender los mensajes del propio sistema operativo cuando llegan, interactuando con el
motor de juegos cuando no existan mensajes del sistema operativo por procesar. En
ese caso se ejecuta una iteración del bucle de juego y se repite el mismo proceso.
La principal consecuencia de este enfoque es que los mensajes del sistema ope-
message dispatching
rativo tienen prioridad con respecto a aspectos críticos como el bucle de renderizado.
Por ejemplo, si la propia ventana en la que se está ejecutando el juego se arrastra o su
tamaño cambia, entonces el juego se congelará a la espera de finalizar el tratamiento
de eventos recibidos por el propio sistema operativo.
17 glutInitWindowSize(640, 480);
18 glutCreateWindow("Session #04 - Solar System");
19
20 // Definición de las funciones de retrollamada.
21 glutDisplayFunc(display);
22 glutReshapeFunc(resize);
23 // Eg. update se ejecutará cuando el sistema
24 // capture un evento de teclado.
C6
25 // Signatura de glutKeyboardFunc:
26 // void glutKeyboardFunc(void (*func)
27 // (unsigned char key, int x, int y));
28 glutKeyboardFunc(update);
29
30 glutMainLoop();
31
32 return 0;
33 }
Menú Game
Intro Juego
ppal over
Pausa
Figura 6.5: Visión general de una máquina de estados finita que representa los estados más comunes en
cualquier juego.
Desde un punto de vista general, los juegos se pueden dividir en una serie de
etapas o estados que se caracterizan no sólo por su funcionamiento sino también por
la interacción con el usuario o jugador. Típicamente, en la mayor parte de los juegos
es posible diferenciar los siguientes estados:
C6
1 1 1 1..*
InputManager GameManager GameState
1 1
OIS:: OIS::
IntroState PlayState PauseState
Keyboard Mouse
Ogre::
Singleton
Figura 6.6: Diagrama de clases del esquema de gestión de estados de juego con Ogre3D. En un tono más
oscuro se reflejan las clases específicas de dominio.
www.ogre3d.org/tikiwiki/Managing+Game+States+with+OGRE
[178] CAPÍTULO 6. GESTIÓN DE RECURSOS
3. Gestión básica de eventos antes y después del renderizado (líneas ✂37-38 ✁),
operaciones típicas de la clase Ogre::FrameListener.
C6
44 void pushState (GameState* state) {
45 GameManager::getSingletonPtr()->pushState(state);
46 }
47 void popState () {
48 GameManager::getSingletonPtr()->popState();
49 }
50
51 };
52
53 #endif
Sin embargo, antes de pasar a discutir esta clase, en el diseño discutido se con-
templa la definición explícita de la clase InputManager, como punto central para la
gestión de eventos de entrada, como por ejemplo los de teclado o de ratón.
¿Ogre::Singleton? El InputManager sirve como interfaz para aquellas entidades que estén interesa-
das en procesar eventos de entrada (como se discutirá más adelante), ya que mantiene
La clase InputManager implementa operaciones para añadir y eliminar listeners de dos tipos: i) OIS::KeyListener y ii)
el patrón Singleton mediante las uti-
lidades de Ogre3D. Es posible utili- OIS::MouseListener. De hecho, esta clase hereda de ambas clases. Además, imple-
zar otros esquemas para que su im- menta el patrón Singleton con el objetivo de que sólo exista una única instancia de la
plementación no depende de Ogre misma.
y se pueda utilizar con otros frame-
works.
Listado 6.5: Clase InputManager.
1 // SE OMITE PARTE DEL CÓDIGO FUENTE.
2 // Gestor para los eventos de entrada (teclado y ratón).
3 class InputManager : public Ogre::Singleton<InputManager>,
4 public OIS::KeyListener, public OIS::MouseListener {
5 public:
6 InputManager ();
7 virtual ~InputManager ();
8
9 void initialise (Ogre::RenderWindow *renderWindow);
10 void capture ();
11
12 // Gestión de listeners.
13 void addKeyListener (OIS::KeyListener *keyListener,
14 const std::string& instanceName);
15 void addMouseListener (OIS::MouseListener *mouseListener,
16 const std::string& instanceName );
17 void removeKeyListener (const std::string& instanceName);
18 void removeMouseListener (const std::string& instanceName);
19 void removeKeyListener (OIS::KeyListener *keyListener);
20 void removeMouseListener (OIS::MouseListener *mouseListener);
21
22 OIS::Keyboard* getKeyboard ();
23 OIS::Mouse* getMouse ();
24
25 // Heredados de Ogre::Singleton.
26 static InputManager& getSingleton ();
27 static InputManager* getSingletonPtr ();
28
29 private:
30 // Tratamiento de eventos.
31 // Delegará en los listeners.
32 bool keyPressed (const OIS::KeyEvent &e);
33 bool keyReleased (const OIS::KeyEvent &e);
34
35 bool mouseMoved (const OIS::MouseEvent &e);
[180] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
39
40 bool mouseMoved (const OIS::MouseEvent &e);
41 bool mousePressed (const OIS::MouseEvent &e, OIS::MouseButtonID
id);
42 bool mouseReleased (const OIS::MouseEvent &e, OIS::MouseButtonID
id);
43
44 // Gestor de eventos de entrada.
45 InputManager *_inputMgr;
46 // Estados del juego.
47 std::stack<GameState*> _states;
48 };
✄
Note que esta clase contiene una función miembro start() (línea ✂12 ✁), definida de
manera explícita para inicializar el gestor de juego, establecer el estado inicial (pasado
como parámetro) y arrancar el bucle de renderizado.
PlayState
Listado 6.8: Clase GameManager. Función changeState().
1 void
2 GameManager::changeState
3 (GameState* state) IntroState
4 {
5 // Limpieza del estado actual.
6 if (!_states.empty()) {
7 // exit() sobre el último estado.
8 _states.top()->exit();
tecla 'p'
9 // Elimina el último estado.
10 _states.pop();
11 }
12 // Transición al nuevo estado.
13 _states.push(state);
14 // enter() sobre el nuevo estado.
15 _states.top()->enter();
16 }
PauseState
Otro aspecto relevante del diseño de esta clase es la delegación de eventos de en-
trada asociados a la interacción por parte del usuario con el teclado y el ratón. El
diseño discutido permite delegar directamente el tratamiento del evento al estado ac-
tivo, es decir, al estado que ocupe la cima de la pila. Del mismo modo, se traslada
a dicho estado la implementación de las funciones frameStarted() y frameEnded().
El siguiente listado de código muestra cómo la implementación de, por ejemplo, la
función keyPressed() es trivial.
PlayState
Listado 6.9: Clase GameManager. Función keyPressed().
1 bool
2 GameManager::keyPressed
3 (const OIS::KeyEvent &e)
IntroState
4 {
5 _states.top()->keyPressed(e);
6 return true;
7 }
Figura 6.7: Actualización de la pi-
la de estados para reanudar el juego
(evento teclado ’p’).
6.1.5. Definición de estados concretos
Este esquema de gestión de estados general, el cual contiene una clase genérica
GameState, permite la definición de estados específicos vinculados a un juego en par-
ticular. En la figura 6.6 se muestra gráficamente cómo la clase GameState se extiende
para definir tres estados:
C6
A la hora de llevar a cabo dicha implementación, se ha optado por utilizar el pa-
trón Singleton, mediante la clase Ogre::Singleton, para garantizar que sólo existe una
instacia de un objeto por estado.
El siguiente listado de código muestra una posible declaración de la clase IntroS-
tate. Como se puede apreciar, esta clase hace uso de las funciones típicas para el
tratamiento de eventos de teclado y ratón.
4 }
5
6 void PauseState::keyPressed (const OIS::KeyEvent &e) {
7 if (e.key == OIS::KC_P) // Tecla p ->Estado anterior.
8 popState();
9 }
C6
1 0..*
NuevoGestor NuevoRecursoPtr NuevoRecurso
Figura 6.11: Diagrama de clases de las principales clases utilizadas en Ogre3D para la gestión de recursos.
En un tono más oscuro se reflejan las clases específicas de dominio.
Carga de niveles Debido a las restricciones hardware que una determinada plataforma de juegos
puede imponer, resulta fundamental hacer uso de un mecanismo de gestión de recur-
Una de las técnicas bastantes comu-
nes a la hora de cargar niveles de sos que sea flexible y escalable para garantizar el diseño y gestión de cualquier tipo
juego consiste en realizarla de ma- de recurso, junto con la posibilidad de cargarlo y liberarlo en tiempo de ejecución,
nera previa al acceso a dicho nivel. respectivamente. Por ejemplo, si piensa en un juego de plataformas estructurado en
Por ejemplo, se pueden utilizar es- diversos niveles, entonces no sería lógico hacer una carga inicial de todos los nive-
tructuras de datos para representar
los puntos de acceso de un nivel al les al iniciar el juego. Por el contrario, sería más adecuado cargar un nivel cuando el
siguiente para adelantar el proceso jugador vaya a acceder al mismo.
de carga. El mismo planteamiento
se puede utilizar para la carga de En Ogre, la gestión de recursos se lleva a cabo utilizando principalmente cuatro
texturas en escenarios. clases (ver figura 6.11):
Básicamente, el desarrollador que haga uso del planteamiento que Ogre ofrece
para la gestión de recursos tendrá que implementar algunas funciones heredadas de
las clases anteriormente mencionadas, completando así la implementación específica
del dominio, es decir, específica de los recursos a gestionar. Más adelante se discute
en detalle un ejemplo relativo a los recursos de sonido.
[186] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
7 // Inicialización de SDL.
8 if (SDL_Init(SDL_INIT_VIDEO) != 0) {
9 fprintf(stderr, "Unable to initialize SDL: %s\n",
10 SDL_GetError());
11 return -1;
12 }
13
14 // Cuando termine el programa, llamada a SQLQuit().
15 atexit(SDL_Quit);
16 // Activación del double buffering.
17 SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
18
19 // Establecimiento del modo de vídeo con soporte para OpenGL.
20 screen = SDL_SetVideoMode(640, 480, 16, SDL_OPENGL);
21 if (screen == NULL) {
22 fprintf(stderr, "Unable to set video mode: %s\n",
23 SDL_GetError());
24 return -1;
25 }
26
27 SDL_WM_SetCaption("OpenGL with SDL!", "OpenGL");
28 // ¡Ya es posible utilizar comandos OpenGL!
29 glViewport(80, 0, 480, 480);
30
31 glMatrixMode(GL_PROJECTION);
32 glLoadIdentity();
33 glFrustum(-1.0, 1.0, -1.0, 1.0, 1.0, 100.0);
34 glClearColor(1, 1, 1, 0);
35
36 glMatrixMode(GL_MODELVIEW); glLoadIdentity();
37 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
38
39 // Renderizado de un triángulo.
40 glBegin(GL_TRIANGLES);
41 glColor3f(1.0, 0.0, 0.0); glVertex3f(0.0, 1.0, -2.0);
42 glColor3f(0.0, 1.0, 0.0); glVertex3f(1.0, -1.0, -2.0);
43 glColor3f(0.0, 0.0, 1.0); glVertex3f(-1.0, -1.0, -2.0);
44 glEnd();
45
46 glFlush();
47 SDL_GL_SwapBuffers(); // Intercambio de buffers.
48 SDL_Delay(5000); // Espera de 5 seg.
49
50 return 0;
51 }
✄
Como se puede apreciar en el listado anterior, la línea ✂20 ✁establece el modo de ví-
deo con soporte para
✄ OpenGL para, posteriormente, hacer uso de comandos OpenGL
a partir de la línea ✂29 ✁. Sin embargo, note cómo el
✄ intercambio del contenido entre el
front buffer y el back buffer se realiza en la línea ✂47 ✁mediante una primitiva de SDL.
8 basic_sdl_opengl: basic_sdl_opengl.o
9 $(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS)
10
11 basic_sdl_opengl.o: basic_sdl_opengl.c
12 $(CC) $(CFLAGS) $^ -o $@
13
14 clean:
15 @echo Cleaning up...
16 rm -f *~ *.o basic_sdl_opengl
17 @echo Done.
18
19 vclean: clean
Reproducción de música
C6
constructor de la clase padre para poder instanciar un recurso, además de inicializar
✄
las propias variables miembro.
Por otra parte, las funciones de manejo básico del track (líneas ✂16-22 ✁) son en
realidad una interfaz para manejar de una manera adecuada la biblioteca SDL_mixer.
Por ejemplo, la función miembro play simplemente interactúa con SDL para com-
probar si la canción estaba pausada y, en ese caso, reanudarla. Si la canción no estaba
pausada, entonces dicha función la reproduce desde el principio. Note cómo se hace
uso del gestor de logs de Ogre3D para almacenar en un archivo la posible ocurrencia
de algún tipo de error.
Figura 6.15: Es posible incluir Listado 6.15: Clase Track. Función play().
efectos de sonidos más avanzados, 1 void
como por ejemplo reducir el nivel 2 Track::play
de volumen a la hora de finalizar la 3 (int loop)
reproducción de una canción. 4 {
5 Ogre::LogManager* pLogManager =
6 Ogre::LogManager::getSingletonPtr();
7
8 if(Mix_PausedMusic()) // Estaba pausada?
9 Mix_ResumeMusic(); // Reanudación.
10
11 // Si no, se reproduce desde el principio.
12 else {
13 if (Mix_PlayMusic(_pTrack, loop) == -1) {
14 pLogManager->logMessage("Track::play() Error al....");
15 throw (Ogre::Exception(Ogre::Exception::ERR_FILE_NOT_FOUND,
16 "Imposible reproducir...",
17 "Track::play()"));
18 }
19 }
20 }
Dos de las funciones más importantes de cualquier tipo de recurso que extienda de
Ogre::Resource son loadImpl() y unloadImpl(), utilizadas para cargar y liberar el re-
curso, respectivamente. Obviamente, cada tipo de recurso delegará en la funcionalidad
necesaria para llevar a cabo dichas tareas. En el caso de la gestión básica del sonido,
estas funciones delegarán principalmente en SDL_mixer. A continuación se muestra
el código fuente de dichas funciones.
Debido a la necesidad de manejar de manera eficiente los recursos de sonido, la
solución discutida en esta sección contempla la definición de punteros inteligentes a
través de la clase TrackPtr. La justificación de esta propuesta, como ya se introdujo
anteriormente, consiste en evitar la duplicidad de recursos, es decir, en evitar que un
mismo recurso esté cargado en memoria principal más de una vez.
7 findResourceFileInfo(mGroup, mName);
8
9 for (Ogre::FileInfoList::const_iterator i = info->begin();
10 i != info->end(); ++i) {
11 _path = i->archive->getName() + "/" + i->filename;
12 }
13
14 if (_path == "") { // Archivo no encontrado...
15 // Volcar en el log y lanzar excepción.
16 }
17
18 // Cargar el recurso de sonido.
19 if ((_pTrack = Mix_LoadMUS(_path.c_str())) == NULL) {
20 // Si se produce un error al cargar el recurso,
21 // volcar en el log y lanzar excepción.
22 }
23
24 // Cálculo del tamaño del recurso de sonido.
25 _size = ...
26 }
27
28 void
29 Track::unloadImpl()
30 {
31 if (_pTrack) {
32 // Liberar el recurso de sonido.
33 Mix_FreeMusic(_pTrack);
34 }
35 }
Ogre3D permite el uso de punteros inteligentes compartidos, definidos en la clase resource 2 references
Ogre::SharedPtr, con el objetivo de parametrizar el recurso definido por el desarro-
llador, por ejemplo Track, y almacenar, internamente, un contador de referencias a
dicho recurso. Básicamente, cuando el recurso se copia, este contador se incrementa.
Si se destruye alguna referencia al recurso, entonces el contador se decrementa. Este
esquema permite liberar recursos cuando no se estén utilizando en un determinado ptr refs ptr refs
momento y compartir un mismo recurso entre varias entidades.
El listado de código 6.17 muestra la implementación de la clase TrackPtr, la cual Figura 6.16: El uso de smart poin-
incluye una serie de funciones (básicamente constructores y asignador de copia) here- ters optimiza la gestión de recursos
dadas de Ogre::SharedPtr. A modo de ejemplo, también se incluye el código asociado y facilita su liberación.
al constructor de copia. Como se puede apreciar, en él se incrementa el contador de
referencias al recurso.
Una vez implementada la lógica necesaria para instanciar y manejar recursos de
sonido, el siguiente paso consiste en definir un gestor o manager específico para cen-
tralizar la administración del nuevo tipo de recurso. Ogre3D facilita enormemente esta
tarea gracias a la clase Ogre::ResourceManager. En el caso particular de los recursos
de sonido se define la clase TrackManager, cuyo esqueleto se muestra en el listado
de código 6.18.
Esta clase no sólo hereda del gestor de recursos de Ogre, sino que también lo hace
de la clase Ogre::Singleton con el objetivo de manejar una única instancia del gestor
de recursos de sonido. Las funciones más relevantes son las siguientes:
✄
load() (líneas ✂10-11 ✁), que permite la carga de canciones por parte del desarro-
llador. Si el recurso a cargar no existe, entonces lo creará internamente utilizan-
do la función que se comenta a continuación.
✄
createImpl() (líneas ✂18-23 ✁), función que posibilita la creación de un nuevo
recurso, es decir, una nueva instancia de la clase Track. El desarrollador es res-
ponsable de realizar la carga del recurso una vez que ha sido creado.
6.2. Gestión básica de recursos [191]
C6
5 // y operador de asignación.
6 TrackPtr(): Ogre::SharedPtr<Track>() {}
7 explicit TrackPtr(Track* m): Ogre::SharedPtr<Track>(m) {}
8 TrackPtr(const TrackPtr &m): Ogre::SharedPtr<Track>(m) {}
9 TrackPtr(const Ogre::ResourcePtr &r);
10 TrackPtr& operator= (const Ogre::ResourcePtr& r);
11 };
12
13 TrackPtr::TrackPtr
14 (const Ogre::ResourcePtr &resource):
15 Ogre::SharedPtr<Track>()
16 {
17 // Comprobar la validez del recurso.
18 if (resource.isNull())
19 return;
20
21 // Para garantizar la exclusión mutua...
22 OGRE_LOCK_MUTEX(*resource.OGRE_AUTO_MUTEX_NAME)
23 OGRE_COPY_AUTO_SHARED_MUTEX(resource.OGRE_AUTO_MUTEX_NAME)
24
25 pRep = static_cast<Track*>(resource.getPointer());
26 pUseCount = resource.useCountPointer();
27 useFreeMethod = resource.freeMethod();
28
29 // Incremento del contador de referencias.
30 if (pUseCount)
31 ++(*pUseCount);
32 }
Con las tres clases que se han discutido en esta sección ya es posible realizar la
carga de recursos de sonido, delegando en la biblioteca SDL_mixer, junto con su ges-
tión y administración básicas. Este esquema encapsula la complejidad del tratamiento
del sonido, por lo que en cualquier momento se podría sustituir dicha biblioteca por
otra.
Más adelante se muestra un ejemplo concreto en el que se hace uso de este tipo
de recursos sonoros. Sin embargo, antes de discutir este ejemplo de integración se
planteará el soporte de efectos de sonido, los cuales se podrán mezclar con el tema o
track principal a la hora de desarrollar un juego. Como se planteará a continuación, la
filosofía de diseño es exactamente igual que la planteada en esta sección.
Además de llevar a cabo la reproducción del algún tema musical durante la eje-
cución de un juego, la incorporación de efectos de sonido es esencial para alcanzar
un buen grado de inmersión y que, de esta forma, el jugador se sienta como parte del
Figura 6.17: La integración de propio juego. Desde un punto de vista técnico, este esquema implica que la biblioteca
efectos de sonido es esencial para de desarrollo permita la mezcla de sonidos. En el caso de SDL_mixer es posible llevar
dotar de realismo la parte sonora del a cabo dicha integración.
videojuego y complementar la parte
gráfica.
[192] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
La diferencia más sustancial con respecto al gestor de sonido reside en que el tipo de
recurso mantiene un identificador textual distinto y que, en el caso de los efectos de
sonido, se lleva a cabo una reserva explícita de 32 canales de audio. Para ello, se hace
uso de una función específica de la biblioteca SDL_mixer.
Para llevar a cabo la integración de los aspectos básicos previamente discutidos so-
bre la gestión de música y efectos de sonido se ha tomado como base el código fuente
de la sesión de iluminación del módulo 2, Programación Gráfica. En este ejemplo se
hacía uso de una clase MyFrameListener para llevar a cabo la gestión de los eventos
de teclado.
Por una parte, este ejemplo se ha extendido para incluir la reproducción ininte-
rrumpida de un tema musical, es decir, un tema que se estará reproduciendo desde que
se inicia la aplicación hasta que ésta finaliza su ejecución. Por otra parte, la reproduc-
ción de efectos de sonido adicionales está vinculada a la generación de ciertos eventos
de teclado. En concreto, cada vez que el usuario pulsa las teclas ’1’ ó ’2’, las cuales
están asociadas a dos esquemas diferentes de cálculo del sombreado, la aplicación re-
producirá un efecto de sonido puntual. Este efecto de sonido se mezclará de manera
adecuada con el track principal.
El siguiente listado de código muestra la nueva función miembro introducida en la
clase MyApp para realizar la carga de recursos asociada a la biblioteca SDL_mixer.
Ogre::
TrackManager
FrameListener
1
1
1 1 1 1
SoundFXManager MyApp MyFrameListener
Figura 6.18: Diagrama simplificado de clases de las principales entidades utilizadas para llevar a cabo la
integración de música y efectos de sonido.
5 if (SDL_Init(SDL_INIT_AUDIO) < 0)
6 return false;
7 // Llamar a SDL_Quit al terminar.
8 atexit(SDL_Quit);
9
10 // Inicializando SDL mixer...
11 if (Mix_OpenAudio(MIX_DEFAULT_FREQUENCY, MIX_DEFAULT_FORMAT,
12 MIX_DEFAULT_CHANNELS, 4096) < 0)
13 return false;
14
15 // Llamar a Mix_CloseAudio al terminar.
16 atexit(Mix_CloseAudio);
17
18 return true;
19
20 }
Finalmente, sólo hay que reproducir los eventos de sonido cuando así sea necesa-
rio. En este ejemplo, dichos eventos se reproducirán cuando se indique, por parte del
usuario, el esquema de cálculo de sombreado mediante las teclas ’1’ ó ’2’. Dicha ac-
tivación se realiza, por simplificación, en la propia clase FrameListener; en concreto,
en la función miembro frameStarted a la hora de capturar los eventos de teclado.
El esquema planteado para la gestión de sonido mantiene la filosofía de delegar el
C6
tratamiento de eventos de teclado, al menos los relativos a la parte sonora, en la clase
principal (MyApp). Idealmente, si la gestión del bucle de juego se plantea en base a
un esquema basado en estados, la reproducción de sonido estaría condicionada por el
estado actual. Este planteamiento también es escalable a la hora de integrar nuevos
estados de juegos y sus eventos de sonido asociados. La activación de dichos eventos
dependerá no sólo del estado actual, sino también de la propia interacción por parte
del usuario.
Figura 6.20: Generalmente, los sis- En el desarrollo multiplataforma, esta API específica proporciona el nivel de
temas de archivos mantienen es- abstracción necesario para no depender del sistema de archivos y de la platafor-
tructuras de árbol o de grafo. ma subyacente.
[196] CAPÍTULO 6. GESTIÓN DE RECURSOS
/home/david/apps/firefox/firefox-bin
En el caso de las consolas, las rutas suelen seguir un convenio muy similar inclu- Encapsulación
so para referirse a distintos volúmenes. Por ejemplo, PlayStation3TM utiliza el prefijo
/dev_bdvd para referirse al lector de blu-ray, mientras que el prefijo /dev_hddx per- Una vez más, el principio de encap-
sulación resulta fundamental para
mite el acceso a un determinado disco duro. abstraerse de la complejidad asocia-
Las rutas o paths pueden ser absolutas o relativas, en función de si están definidas da al tratamiento del sistema de ar-
chivos y del sistema operativo sub-
teniendo como referencia el directorio raíz del sistema de archivos u otro directorio, yacente.
respectivamente. En sistemas UNIX, dos ejemplos típicos serían los siguientes:
/usr/share/doc/ogre-doc/api/html/index.html
apps/firefox/firefox-bin
El primero de ellos sería una ruta absoluta, ya que se define en base al directorio
raíz. Por otra parte, la segunda sería una ruta relativa, ya que se define en relación al
directorio /home/david.
6.3. El sistema de archivos [197]
C6
bin boot dev etc home lib media usr var
5 www.boost.org/libs/filesystem/
[198] CAPÍTULO 6. GESTIÓN DE RECURSOS
El lenguaje de programación C permite manejar dos APIs para gestionar las opera-
ciones más relevantes en relación al contenido de un archivo, como la apertura, lectura,
escritura o el cierre. La primera de ellas proporciona E/S con buffers mientras que la
segunda no.
La diferencia entre ambas reside en que la API con E/S mediante buffer gestiona de
manera automática el uso de los propios buffers sin necesidad de que el programador
C6
asuma dicha responsabilidad. Por el contrario, la API de C que no proporciona E/S con
buffers tiene como consecuencia directa que el programador ha de asignar memoria
para dichos buffers y gestionarlos. La tabla 6.1 muestra las principales operaciones de
dichas APIs.
Finalmente, es muy importante tener en cuenta que las bibliotecas de E/S estándar
de C son síncronas. En otras palabras, ante una invocación de E/S, el programa se
queda bloqueado hasta que se atiende por completo la petición. El listado de código
anterior muestra un ejemplo de llamada bloqueante mediante la operación fread().
Evidentemente, el esquema síncrono presenta un inconveniente muy importante,
ya que bloquea al programa mientras la operación de E/S se efectúa, degradando así
el rendimiento global de la aplicación. Un posible solución a este problema consiste
en adoptar un enfoque asíncrono, tal y como se discute en la siguiente sección.
C6
Desplazar int fseek(FILE *stream, long offset, int whence);
Obtener offset long ftell(FILE *stream);
Lectura línea char *fgets(char *s, int size, FILE *stream);
Escritura línea int fputs(const char *s, FILE *stream);
Lectura cadena int fscanf(FILE *stream, const char *format, ...);
Escritura cadena int fprintf(FILE *stream, const char *format, ...);
Obtener estado int fstat(int fd, struct stat *buf);
Cuadro 6.1: Resumen de las principales operaciones de las APIs de C para la gestión de E/S (con y sin buffers).
La figura 6.24 muestra de manera gráfica cómo dos agentes se comunican de ma-
nera asíncrona mediante un objeto de retrollamada. Básicamente, el agente notificador
envía un mensaje al agente receptor de manera asíncrona. Esta invocación tiene dos
parámetros: i) el propio mensaje m y ii) un objeto de retrollamada cb. Dicho objeto
será usado por el agente receptor cuando éste termine de procesar el mensaje. Mien-
tras tanto, el agente notificador continuará su flujo normal de ejecución, sin necesidad
de bloquearse por el envío del mensaje.
Callbacks La mayoría de las bibliotecas de E/S asíncrona existentes permiten que el progra-
Las funciones de retrollamada se
ma principal espera una cierta cantidad de tiempo antes de que una operación de E/S
suelen denominar comúnmente se complete. Este planteamiento puede ser útil en situaciones en las que los datos de
callbacks. Este concepto no sólo dicha operación se necesitan para continuar con el flujo normal de trabajo. Otras APIs
se usa en el ámbito de la E/S asín- pueden proporcionar incluso una estimación del tiempo que tardará en completarse
crona, sino también en el campo
de los sistemas distribuidos y los
una operación de E/S, con el objetivo de que el programador cuente con la mayor
middlewares de comunicaciones. cantidad posible de información. Así mismo, también es posible encontrarse con ope-
raciones que asignen deadlines sobre las propias peticiones, con el objetivo de plantear
un esquema basado en prioridades. Si el deadline se cumple, entonces también suele
ser posible asignar el código a ejecutar para contemplar ese caso en particular.
[202] CAPÍTULO 6. GESTIÓN DE RECURSOS
Agente 1
� : Agente n
Notificador Receptor
1
<<crear ()>> : Objeto
callback
recibir_mensaje (cb, m)
Procesar
mensaje
responder ()
responder ()
Figura 6.24: Esquema gráfico representativo de una comunicación asíncrona basada en objetos de retrolla-
mada.
C6
El desarrollo de esta biblioteca se justifica por la necesidad de interacción en-
tre programas, ya sea mediante ficheros, redes o mediante la propia consola, que no
pueden quedarse a la espera ante operaciones de E/S cuyo tiempo de ejecución sea
elevado. En el caso del desarrollo de videojuegos, una posible aplicación de este en-
foque, tal y como se ha introducido anteriormente, sería la carga en segundo plano
de recursos que serán utilizados en el futuro inmediato. La situación típica en este
contexto sería la carga de los datos del siguiente nivel de un determinado juego.
Según el propio desarrollador de la biblioteca7 , Asio proporciona las herramientas
necesarias para manejar este tipo de problemática sin la necesidad de utilizar, por parte
del programador, modelos de concurrencia basados en el uso de hilos y mecanismos
de exclusión mutua. Aunque la biblioteca fue inicialmente concebida para la proble-
mática asociada a las redes de comunicaciones, ésta es perfectamente aplicable para
wait llevar a cabo una E/S asíncrona asociada a descriptores de ficheros o incluso a puertos
serie.
Los principales objetivos de diseño de Boost.Asio son los siguientes:
En determinados contextos resulta muy deseable llevar a cabo una espera asíncro- In the meantime...
na, es decir, continuar ejecutando instrucciones mientras en otro nivel de ejecución se
Recuerde que mientras se lleva a
realizan otras tareas. Si se utiliza la biblioteca Boost.Asio, es posible definir mane- cabo una espera asíncrona es posi-
jadores de código asociados a funciones de retrollamada que se ejecuten mientras el ble continuar ejecutando operacio-
programa continúa la ejecución de su flujo principal. Asio también proporciona meca- nes en el hilo principal. Aproveche
nismos para que el programa no termine mientras haya tareas por finalizar. este esquema para obtener un mejor
rendimiento en sistemas con más de
El siguiente listado de código muestra cómo el ejemplo anterior se puede modificar un procesador.
para llevar a cabo una espera asíncrona, asociando en este caso un temporizador que
controla la ejecución de una función de retrollamada.
25
26 return 0;
27 }
C6
$ Esperando a print()...
$ Hola Mundo!
Sin embargo, pasarán casi 3 segundos entre la impresión de una secuencia✄y otra,
ya que inmediatamente después de la llamada a la
✄ espera asíncrona (línea ✂15 ✁) se
ejecutará la primera secuencia de impresión✄ (línea
✂17 ✁), mientras que la ejecución de
la función de retrollamada print() (líneas ✂6-8 ✁) se demorará 3 segundos.
Otra opción imprescindible a la hora de gestionar estos manejadores reside en la
posibilidad de realizar un paso de parámetros, en función del dominio de la aplica-
ción que se esté desarrollando. El siguiente listado de código muestra cómo llevar a
cabo dicha tarea.
Como se puede apreciar, las llamadas a la función async_wait() varían con res-
pecto a otros ejemplos, ya que se indica de manera explícita los argumentos relevantes
para la función de retrollamada. En este caso, dichos argumentos son la propia función
de retrollamada, para realizar llamadas recursivas, el timer para poder prolongar en un
segundo su duración en cada llamada, y una variable entera que se irá incrementando
en cada llamada.
Flexibilidad en Asio La biblioteca Boost.Asio también permite encapsular las funciones de retrollamada
que se ejecutan de manera asíncrona como funciones miembro de una clase. Este
La biblioteca Boost.Asio es muy esquema mejora el diseño de la aplicación y permite que el desarrollador se abstraiga
flexible y posibilita la llamada asín-
crona a una función que acepta un de la implementación interna de la clase. El siguiente listado de código muestra una
número arbitrario de parámetros. posible modificación del ejemplo anterior mediante la definición de una clase Counter,
Éstos pueden ser variables, punte- de manera que la función count pasa a ser una función miembro de dicha clase.
ros, o funciones, entre otros.
[206] CAPÍTULO 6. GESTIÓN DE RECURSOS
20 _timer1.async_wait(_strand.wrap
21 (boost::bind(&Counter::count1, this)));
22 }
23 }
24
25 // IDEM que count1 pero sobre timer2 y count2
26 void count2() { /* src */ }
27
C6
28 private:
29 boost::asio::strand _strand;
30 boost::asio::deadline_timer _timer1, _timer2;
31 int _count;
32 };
33
34 int main() {
35 boost::asio::io_service io;
36 // run() se llamará desde dos threads (principal y boost)
37 Counter c(io);
38 boost::thread t(boost::bind(&boost::asio::io_service::run, &io));
39 io.run();
40 t.join();
41
42 return 0;
43 }
XML mantiene un alto nivel semántico, está bien soportado y estandarizado y permite
asociar estructuras de árbol bien definidas para encapsular la información relevante.
Además, posibilita que el contenido a importar sea legible por los propios programa-
dores.
Es importante resaltar que este capítulo está centrado en la parte de importación
de datos hacia el motor de juegos, por lo que el proceso de exportación no se discutirá
a continuación (si se hará, no obstante, en el módulo 2 de Programación Gráfica).
Ogre Exporter
1. Exportación, con el objetivo de obtener una representación de los datos 3D.
2. Importación, con el objetivo de acceder a dichos datos desde el motor de juegos
o desde el propio juego.
Importer
Archivos binarios
C6
En el caso de utilizar un formato no estandarizado, es necesario explicitar los
separadores o tags existentes entre los distintos campos del archivo en texto plano.
Por ejemplo, se podría pensar en separadores como los dos puntos, el punto y coma y
el retorno de carro para delimitar los campos de una determinada entidad y una entidad
de la siguiente, respectivamente.
XML
Figura 6.29: Visión conceptual de Sin embargo, no todo son ventajas. El proceso de parseado o parsing es rela-
la relación de XML con otros len-
guajes.
tivamente lento. Esto implica que algunos motores hagan uso de formatos binarios
propietarios, los cuales son más rápidos de parsear y mucho más compactos que los
archivos XML, reduciendo así los tiempos de importación y de carga.
El parser Xerces-C++
Como se ha comentado anteriormente, una de los motivos por los que XML está
tan extendido es su amplio soporte a nivel de programación. En otras palabras, prác-
ticamente la mayoría de lenguajes de programación proporcionan bibliotecas, APIs y
herramientas para procesar y generar contenidos en formato XML.
En el caso del lenguaje C++, estándar de facto en la industria del videojuego, el
parser Xerces-C++11 es una de las alternativas más completas para llevar a cabo el
procesamiento de ficheros XML. En concreto, esta herramienta forma parte del pro-
Figura 6.30: Logo principal del yecto Apache XML12 , el cual gestiona un número relevante de subproyectos vincula-
proyecto Apache. dos al estándar XML.
11 http://xerces.apache.org/xerces-c/
12 http://xml.apache.org/
[210] CAPÍTULO 6. GESTIÓN DE RECURSOS
C6
S
Figura 6.32: Representación interna bidimensional en forma de grafo del escenario de NoEscapeDemo.
Los nodos de tipo S (spawn) representan puntos de nacimiento, mientras que los nodos de tipo D (drain)
representan sumideros, es decir, puntos en los que los fantasmas desaparecen.
El nodo raíz En principio, la información del escenario y de las cámaras virtuales será impor-
La etiqueta <data> es el nodo raíz tada al juego, haciendo uso del importador cuyo diseño se discute a continuación. Sin
del documento XML. Sus posibles embargo, antes se muestra un posible ejemplo de la representación de estos mediante
nodos hijo están asociados con las el formato definido para la demo en cuestión.
etiquetas <graph> y <camera>.
En concreto, el siguiente listado muestra la parte de información asociada a la
estructura de grafo que conforma el escenario. Como se puede apreciar, la estructura
graph está compuesta por una serie de vértices (vertex) y de arcos (edge).
Por una parte, en los vértices del grafo se incluye su posición en el espacio 3D
mediante las etiquetas <x>, <y> y <z>. Además, cada vértice tiene como atributo
un índice, que lo identifica unívocamente, y un tipo, el cual puede ser spawn (punto
de generación) o drain (punto de desaparición), respectivamente. Por otra parte, los
arcos permiten definir la estructura concreta del grafo a importar mediante la etiqueta
<vertex>.
Vértices y arcos En caso de que sea necesario incluir más contenido, simplemente habrá que ex-
tender el formato definido. XML facilita enormemente la escalabilidad gracias a su
En el formato definido, los vértices
se identifican a través del atributo esquema basado en el uso de etiquetas y a su estructura jerárquica.
index. Estos IDs se utilizan en la eti-
queta <edge> para especificar los
dos vértices que conforman un ar-
co.
[212] CAPÍTULO 6. GESTIÓN DE RECURSOS
Listado 6.30: Ejemplo de fichero XML usado para exportar e importar contenido asociado al
grafo del escenario.
1 <?xml version=’1.0’ encoding=’UTF-8’?>
2 <data>
3
4 <graph>
5
6 <vertex index="1" type="spawn">
7 <x>1.5</x> <y>2.5</y> <z>-3</z>
8 </vertex>
9 <!-- More vertexes... -->
10 <vertex index="4" type="drain">
11 <x>1.5</x> <y>2.5</y> <z>-3</z>
12 </vertex>
13
14 <edge>
15 <vertex>1</vertex> <vertex>2</vertex>
16 </edge>
17 <edge>
18 <vertex>2</vertex> <vertex>4</vertex>
19 </edge>
20 <!-- More edges... -->
21
22 </graph>
23
24 <!-- Definition of virtual cameras -->
25 </data>
Lógica de dominio
1 #include <vector>
2 #include <Camera.h>
6.4. Importador de datos de intercambio [213]
Listado 6.31: Ejemplo de fichero XML usado para exportar e importar contenido asociado a
las cámaras virtuales.
1 <?xml version=’1.0’ encoding=’UTF-8’?>
2 <data>
3 <!-- Graph definition here-->
C6
4
5 <camera index="1" fps="25">
6
7 <path>
8
9 <frame index="1">
10 <position>
11 <x>1.5</x> <y>2.5</y> <z>-3</z>
12 </position>
13 <rotation>
14 <x>0.17</x> <y>0.33</y> <z>0.33</z> <w>0.92</w>
15 </rotation>
16 </frame>
17
18 <frame index="2">
19 <position>
20 <x>2.5</x> <y>2.5</y> <z>-3</z>
21 </position>
22 <rotation>
23 <x>0.17</x> <y>0.33</y> <z>0.33</z> <w>0.92</w>
24 </rotation>
25 </frame>
26
27 <!-- More frames here... -->
28
29 </path>
30
31 </camera>
32
33 <!-- More cameras here... -->
34 </data>
3 #include <Node.h>
4 #include <Graph.h>
5
6 class Scene
7 {
8 public:
9 Scene ();
10 ~Scene ();
11
12 void addCamera (Camera* camera);
13 Graph* getGraph () { return _graph;}
14 std::vector<Camera*> getCameras () { return _cameras; }
15
16 private:
17 Graph *_graph;
18 std::vector<Camera*> _cameras;
19 };
Por otra parte, la clase Graph mantiene la lógica de gestión básica para imple-
mentar una estructura de tipo grafo mediante listas de adyacencia. El siguiente listado
de código muestra dicha clase e integra una función miembro para obtener ✄la lista
de
vértices o nodos adyacentes a partir del identificador de uno de ellos (línea ✂17 ✁).
[214] CAPÍTULO 6. GESTIÓN DE RECURSOS
1 1
Scene Importer Ogre::Singleton
1
1
1..* 1
1 1..*
Camera Graph GraphVertex
1 2 1
1
Figura 6.33: Diagrama de clases de las entidades más relevantes del importador de datos.
26 std::vector<GraphEdge*> _edges;
27 };
C6
2 class Node
3 {
4 public:
5 Node ();
6 Node (const int& index, const string& type,
7 const Ogre::Vector3& pos);
8 ~Node ();
9
10 int getIndex () const { return _index; }
11 string getType () const { return _type; }
12 Ogre::Vector3 getPosition () const { return _position; }
13
14 private:
15 int _index; // Índice del nodo (id único)
16 string _type; // Tipo: generador (spawn), sumidero
(drain)
17 Ogre::Vector3 _position; // Posición del nodo en el espacio 3D
18 };
GraphVertex y Node Respecto al diseño de las cámaras virtuales, las clases Camera y Frame son las
utilizadas para encapsular la información y funcionalidad de las mismas. En esencia,
La clase GraphVertex mantiene co- una cámara consiste en un identificador, un atributo que determina la tasa de frames
mo variable de clase un objeto de ti-
po Node, el cual alberga la informa- por segundo a la que se mueve y una secuencia de puntos clave que conforman el
ción de un nodo en el espacio 3D. camino asociado a la cámara.
La clase Importer
API DOM y memoria El punto de interacción entre los datos contenidos en el documento XML y la
lógica de dominio previamente discutida está representado por la clase Importer. Esta
El árbol generado por el API DOM clase proporciona la funcionalidad necesaria para parsear documentos XML con la
a la hora de parsear un documento
XML puede ser costoso en memo-
estructura planteada anteriormente y rellenar las estructuras de datos diseñadas en la
ria. En ese caso, se podría plantear anterior sección.
otras opciones, como por ejemplo el
uso del API SAX. El siguiente listado de código muestra la declaración de la clase Importer. Como se
puede apreciar, dicha clase hereda de Ogre::Singleton para garantizar que solamente
existe una instancia de dicha clase, accesible con las funciones miembro getSingleton()
y getSingletonPtr()13 .
Note cómo, además de estas✄ dos funciones miembro, la única función miembro
pública es parseScene() (línea ✂9 ✁), la cual se puede utilizar para parsear un docu-
mento XML cuya ruta se especifica en el primer parámetro. El efecto de realizar una
llamada a esta función tendrá como resultado el segundo parámetro de la misma, de ti-
po puntero a objeto de clase Scene, con la información obtenida a partir del documento
XML (siempre y cuando no se produzca ningún error).
6 public:
7 // Única función miembro pública para parsear.
8 void parseScene (const char* path, Scene *scn);
9
10 static Importer& getSingleton (); // Ogre::Singleton.
11 static Importer* getSingletonPtr (); // Ogre::Singleton.
12
13 private:
14 // Funcionalidad oculta al exterior.
15 // Facilita el parseo de las diversas estructuras
16 // del documento XML.
17 void parseCamera (xercesc::DOMNode* cameraNode, Scene* scn);
18
19 void addPathToCamera (xercesc::DOMNode* pathNode, Camera *cam);
20 void getFramePosition (xercesc::DOMNode* node,
21 Ogre::Vector3* position);
22 void getFrameRotation (xercesc::DOMNode* node,
23 Ogre::Vector4* rotation);
24
25 void parseGraph (xercesc::DOMNode* graphNode, Scene* scn);
26 void addVertexToScene (xercesc::DOMNode* vertexNode, Scene* scn);
27 void addEdgeToScene (xercesc::DOMNode* edgeNode, Scene* scn);
28
29 // Función auxiliar para recuperar valores en punto flotante
30 // asociados a una determinada etiqueta (tag).
31 float getValueFromTag (xercesc::DOMNode* node, const XMLCh *tag);
32 };
C6
20 for (XMLSize_t i = 0;
21 i < elementRoot->getChildNodes()->getLength(); ++i ) {
22 DOMNode* node = elementRoot->getChildNodes()->item(i);
23
24 if (node->getNodeType() == DOMNode::ELEMENT_NODE) {
25 // Nodo <camera>?
26 if (XMLString::equals(node->getNodeName(), camera_ch))
27 parseCamera(node, scene);
28 else
29 // Nodo <graph>?
30 if (XMLString::equals(node->getNodeName(), graph_ch))
31 parseGraph(node, scene);
32 }
33 }// Fin for
34 // Liberar recursos.
35 }
Bajo Nivel y Concurrencia
Capítulo 7
David Vallejo Fernández
E
ste capítulo realiza un recorrido por los sistemas de soporte de bajo nivel ne-
cesarios para efectuar diversas tareas críticas para cualquier motor de juegos.
Algunos ejemplos representativos de dichos subsistemas están vinculados al
arranque y la parada del motor, su configuración y a la gestión de cuestiones de más
bajo nivel.
El objetivo principal del presente capítulo consiste en profundizar en dichas tareas
y en proporcionar al lector una visión más detallada de los subsistemas básicos sobre
los que se apoyan el resto de elementos del motor de juegos.
From the ground up! En concreto, en este capítulo se discutirán los siguientes subsistemas:
Los subsistemas de bajo nivel del Subsistema de arranque y parada.
motor de juegos resultan esenciales
para la adecuada integración de ele- Subsistema de gestión de contenedores.
mentos de más alto nivel. Algunos
de ellos son simples, pero la funcio- Subsistema de gestión de cadenas.
nalidad que proporcionan es crítica
para el correcto funcionamiento de
otros subsistemas. El subsistema de gestión relativo a la gestión de contenedores de datos se discutió
en el capítulo 5. En este capítulo se llevó a cabo un estudio de la biblioteca STL y
se plantearon unos criterios básicos para la utilización de contenedores de datos en
el ámbito del desarrollo de videojuegos con C++. Sin embargo, este capítulo dedica
una breve sección a algunos aspectos relacionados con los contenedores que no se
discutieron anteriormente.
Por otra parte, el subsistema de gestión de memoria se estudiará en el módulo 3,
titulado Técnicas Avanzadas de Desarrollo, del presente curso de desarrollo de video-
juegos. Respecto a esta cuestión particular, se hará especial hincapié en las técnicas y
las posibilidades que ofrece C++ en relación a la adecuada gestión de la memoria del
sistema.
219
[220] CAPÍTULO 7. BAJO NIVEL Y CONCURRENCIA
Con el objetivo de abordar esta problemática desde un punto de vista práctico en El arranque y la parada de subsiste-
mas representa una tarea básica y, al
el ámbito del desarrollo de videojuegos, en este capítulo se plantean distintos meca- mismo tiempo, esencial para la co-
nismos de sincronización de hilos haciendo uso de los mecanismos nativos ofrecidos rrecta gestión de los distintos com-
en C++11 y, por otra parte, de la biblioteca de hilos de ZeroC I CE, un middleware de ponentes de la arquitectura de un
comunicaciones que proporciona una biblioteca de hilos que abstrae al desarrollador motor de juegos.
de la plataforma y el sistema operativo subyacentes. Subsistema S
Sistema de Manejo de
Motor de rendering, motor de física,
arranque y parada ca