changeset 456:cae1ae9de430

Add native text support, MSG instructions 3 and 8, and text at the beginning of a stage.
author Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
date Tue, 16 Jul 2013 21:11:40 +0200
parents 6864a38b2413
children 4ccc47828002
files README pytouhou/game/game.pyx pytouhou/game/text.py pytouhou/games/eosd.py pytouhou/lib/_sdl.pxd pytouhou/lib/sdl.pxd pytouhou/lib/sdl.pyx pytouhou/ui/gamerunner.pyx pytouhou/ui/renderer.pxd pytouhou/ui/renderer.pyx pytouhou/ui/texture.pyx pytouhou/ui/window.pyx pytouhou/vm/msgrunner.py setup.py
diffstat 14 files changed, 312 insertions(+), 24 deletions(-) [+]
line wrap: on
line diff
--- a/README
+++ b/README
@@ -16,7 +16,8 @@ Running:
     * Cython
     * A working OpenGL driver
     * SDL2
-    * SDL2_image, SDL2_mixer
+    * SDL2_image, SDL2_mixer, SDL2_ttf
+    * A TTF font file, placed as “font.ttf” in the game directory.
 
 
 Building sample data:
--- a/pytouhou/game/game.pyx
+++ b/pytouhou/game/game.pyx
@@ -19,7 +19,7 @@ from pytouhou.game.bullet cimport Bullet
 from pytouhou.game.enemy cimport Enemy
 from pytouhou.game.item cimport Item
 from pytouhou.game.effect cimport Particle
-from pytouhou.game.text import Text
+from pytouhou.game.text import Text, NativeText
 from pytouhou.game.face import Face
 
 
@@ -46,6 +46,7 @@ cdef class Game:
         self.items = []
         self.labels = []
         self.faces = [None, None]
+        self.texts = [None, None, None, None]
         self.interface = interface
         self.hints = hints
 
@@ -209,6 +210,11 @@ cdef class Game:
         return label
 
 
+    def new_native_text(self, pos, text, align='left'):
+        label = NativeText(pos, text, shadow=True, align=align)
+        return label
+
+
     def new_hint(self, hint):
         pos = hint['Pos']
         #TODO: Scale
@@ -264,6 +270,12 @@ cdef class Game:
             self.update_hints() # Not from this game, so unknown.
         for label in self.labels: #TODO: what priority is it?
             label.update()
+        for text in self.texts: #TODO: what priority is it?
+            if text is not None:
+                text.update()
+        for text in self.native_texts: #TODO: what priority is it?
+            if text is not None:
+                text.update()
         self.update_faces() # Pri XXX
 
         # 5. Clean up
@@ -487,6 +499,11 @@ cdef class Game:
 
         self.effects = filter_removed(self.effects)
         self.labels = filter_removed(self.labels)
+        #self.native_texts = filter_removed(self.native_texts)
+
+        for i, text in enumerate(self.texts):
+            if text is not None and text.removed:
+                self.texts[i] = None
 
         # Disable boss mode if it is dead/it has timeout
         if self.boss and self.boss._enemy.removed:
--- a/pytouhou/game/text.py
+++ b/pytouhou/game/text.py
@@ -135,29 +135,24 @@ class Text(GlyphCollection):
 
 
     def move_timeout_update(self):
-        GlyphCollection.update(self)
         if self.frame % 2:
             for glyph in self.glyphes:
                 glyph.y -= 1
-        if self.frame == self.timeout:
-            self.removed = True
+        self.timeout_update()
 
 
     def fadeout_timeout_update(self):
-        GlyphCollection.update(self)
         if self.frame >= self.start:
             if self.frame == self.start:
                 self.fade(self.duration, 255, lambda x: x)
             elif self.frame == self.timeout - self.duration:
                 self.fade(self.duration, 0, lambda x: x)
-            if self.fade_interpolator:
-                self.fade_interpolator.update(self.frame)
-                self.alpha = int(self.fade_interpolator.values[0])
-                for glyph in self.glyphes:
-                    glyph.sprite.alpha = self.alpha
-                    glyph.sprite.changed = True
-        if self.frame == self.timeout:
-            self.removed = True
+            self.fade_interpolator.update(self.frame)
+            self.alpha = int(self.fade_interpolator.values[0])
+            for glyph in self.glyphes:
+                glyph.sprite.alpha = self.alpha
+                glyph.sprite.changed = True
+        self.timeout_update()
 
 
     def fade(self, duration, alpha, formula):
@@ -167,9 +162,9 @@ class Text(GlyphCollection):
 
 
     def set_timeout(self, timeout, effect=None, duration=0, start=0):
+        self.timeout = timeout + start
         if effect == 'move':
             self.update = self.move_timeout_update
-            self.timeout = timeout + start
         elif effect == 'fadeout':
             self.alpha = 0
             for glyph in self.glyphes:
@@ -177,10 +172,8 @@ class Text(GlyphCollection):
             self.update = self.fadeout_timeout_update
             self.duration = duration
             self.start = start
-            self.timeout = timeout + start
         else:
             self.update = self.timeout_update
-            self.timeout = timeout + start
 
 
 
@@ -236,3 +229,88 @@ class Gauge(Element):
             self.anmrunner = None
 
 
+
+class NativeText(object):
+    def __init__(self, pos, text, gradient=None, alpha=255, shadow=False, align='left'):
+        self.removed = False
+        self.x, self.y = pos
+        self.text = text
+        self.alpha = alpha
+        self.shadow = shadow
+        self.align = align
+        self.frame = 0
+
+        self.gradient = gradient or [(255, 255, 255), (255, 255, 255),
+                                     (128, 128, 255), (128, 128, 255)]
+
+        self.update = self.normal_update
+
+
+    def normal_update(self):
+        self.frame += 1
+
+
+    def timeout_update(self):
+        self.normal_update()
+        if self.frame == self.timeout:
+            self.removed = True
+
+
+    def move_timeout_update(self):
+        if self.frame % 2:
+            self.y -= 1
+        self.timeout_update()
+
+
+    def move_ex_timeout_update(self):
+        if self.frame >= self.start:
+            if self.frame == self.start:
+                self.move_in(self.duration, self.to[0], self.to[1], lambda x: x)
+            elif self.frame == self.timeout - self.duration:
+                self.move_in(self.duration, self.end[0], self.end[1], lambda x: x)
+            if self.offset_interpolator:
+                self.offset_interpolator.update(self.frame)
+                self.x, self.y = self.offset_interpolator.values
+        self.timeout_update()
+
+
+    def fadeout_timeout_update(self):
+        if self.frame >= self.start:
+            if self.frame == self.start:
+                self.fade(self.duration, 255, lambda x: x)
+            elif self.frame == self.timeout - self.duration:
+                self.fade(self.duration, 0, lambda x: x)
+            self.fade_interpolator.update(self.frame)
+            self.alpha = int(self.fade_interpolator.values[0])
+        self.timeout_update()
+
+
+    def fade(self, duration, alpha, formula):
+        self.fade_interpolator = Interpolator((self.alpha,), self.frame,
+                                              (alpha,), self.frame + duration,
+                                              formula)
+
+
+    def move_in(self, duration, x, y, formula):
+        self.offset_interpolator = Interpolator((self.x, self.y), self.frame,
+                                                (x, y), self.frame + duration,
+                                                formula)
+
+
+    def set_timeout(self, timeout, effect=None, duration=0, start=0, to=None, end=None):
+        self.timeout = timeout + start
+        if effect == 'move':
+            self.update = self.move_timeout_update
+        elif effect == 'move_ex':
+            self.update = self.move_ex_timeout_update
+            self.duration = duration
+            self.start = start
+            self.to = to
+            self.end = end
+        elif effect == 'fadeout':
+            self.alpha = 0
+            self.update = self.fadeout_timeout_update
+            self.duration = duration
+            self.start = start
+        else:
+            self.update = self.timeout_update
--- a/pytouhou/games/eosd.py
+++ b/pytouhou/games/eosd.py
@@ -21,7 +21,7 @@ from pytouhou.game.itemtype import ItemT
 from pytouhou.game.player import Player
 from pytouhou.game.orb import Orb
 from pytouhou.game.effect import Effect
-from pytouhou.game.text import Text, Counter, Gauge
+from pytouhou.game.text import Text, Counter, Gauge, NativeText
 from pytouhou.game.background import Background
 
 from pytouhou.vm.eclrunner import ECLMainRunner
@@ -110,14 +110,15 @@ class EoSDGame(Game):
 
         players = [EoSDPlayer(state, self, resource_loader, common.characters[state.character]) for state in player_states]
 
-        common.interface.start_stage(self, stage)
-
         # Load stage data
         self.std = resource_loader.get_stage('stage%d.std' % stage)
 
         background_anm = resource_loader.get_single_anm('stg%dbg.anm' % stage)
         self.background = Background(self.std, background_anm)
 
+        common.interface.start_stage(self, stage)
+        self.native_texts = [common.interface.stage_name, common.interface.song_name]
+
         self.resource_loader = resource_loader #XXX: currently used for texture preload in pytouhou.ui.gamerunner. Wipe it!
 
         Game.__init__(self, players, stage, rank, difficulty,
@@ -183,10 +184,21 @@ class EoSDInterface(object):
             text = 'FINAL STAGE'
         elif stage == 7:
             text = 'EXTRA STAGE'
+
+        self.stage_name = NativeText((192, 200), unicode(game.std.name), shadow=True, align='center')
+        self.stage_name.set_timeout(240, effect='fadeout', duration=60, start=120)
+
+        self.set_song_name(game.std.bgms[0][0])
+
         self.level_start = [Text((16+384/2, 200), self.ascii_anm, text=text, align='center')] #TODO: find the exact location.
         self.level_start[0].set_timeout(240, effect='fadeout', duration=60, start=120)
         self.level_start[0].set_color('yellow')
-        #TODO: use the system text for the stage name, and the song name.
+
+
+    def set_song_name(self, name):
+        #TODO: use the correct animation.
+        self.song_name = NativeText((384, 432), u'♪ ' + name, shadow=True, align='right')
+        self.song_name.set_timeout(240, effect='fadeout', duration=60, start=120)
 
 
     def set_boss_life(self):
--- a/pytouhou/lib/_sdl.pxd
+++ b/pytouhou/lib/_sdl.pxd
@@ -164,3 +164,19 @@ cdef extern from "SDL_mixer.h" nogil:
     int Mix_VolumeMusic(int volume)
 
     int Mix_PlayChannel(int channel, Mix_Chunk *chunk, int loops)
+
+
+cdef extern from "SDL_pixels.h" nogil:
+    ctypedef struct SDL_Color:
+        Uint8 r, g, b, a
+
+
+cdef extern from "SDL_ttf.h" nogil:
+    ctypedef struct TTF_Font:
+        pass
+
+    int TTF_Init()
+    void TTF_Quit()
+    TTF_Font *TTF_OpenFont(const char *filename, int ptsize)
+    void TTF_CloseFont(TTF_Font *font)
+    SDL_Surface *TTF_RenderUTF8_Blended(TTF_Font *font, const char *text, SDL_Color fg)
--- a/pytouhou/lib/sdl.pxd
+++ b/pytouhou/lib/sdl.pxd
@@ -74,9 +74,16 @@ cdef class Chunk:
     cdef void set_volume(self, float volume) nogil
 
 
+cdef class Font:
+    cdef TTF_Font *font
+
+    cdef Surface render(self, unicode text)
+
+
 cdef void init(Uint32 flags) except *
 cdef void img_init(Uint32 flags) except *
 cdef void mix_init(int flags) except *
+cdef void ttf_init() except *
 
 IF UNAME_SYSNAME == "Windows":
     cdef void set_main_ready()
@@ -84,6 +91,7 @@ IF UNAME_SYSNAME == "Windows":
 cdef void quit() nogil
 cdef void img_quit() nogil
 cdef void mix_quit() nogil
+cdef void ttf_quit() nogil
 cdef void gl_set_attribute(SDL_GLattr attr, int value) except *
 cdef list poll_events()
 cdef const Uint8* get_keyboard_state() nogil
--- a/pytouhou/lib/sdl.pyx
+++ b/pytouhou/lib/sdl.pyx
@@ -116,6 +116,27 @@ cdef class Chunk:
         Mix_VolumeChunk(self.chunk, int(volume * 128))
 
 
+cdef class Font:
+    def __init__(self, const char *filename, int ptsize):
+        self.font = TTF_OpenFont(filename, ptsize)
+        if self.font == NULL:
+            raise SDLError(SDL_GetError())
+
+    def __dealloc__(self):
+        if self.font != NULL:
+            TTF_CloseFont(self.font)
+
+    cdef Surface render(self, unicode text):
+        cdef SDL_Color white
+        white = SDL_Color(255, 255, 255, 255)
+        surface = Surface()
+        string = text.encode('utf-8')
+        surface.surface = TTF_RenderUTF8_Blended(self.font, string, white)
+        if surface.surface == NULL:
+            raise SDLError(SDL_GetError())
+        return surface
+
+
 cdef void init(Uint32 flags) except *:
     if SDL_Init(flags) < 0:
         raise SDLError(SDL_GetError())
@@ -131,6 +152,11 @@ cdef void mix_init(int flags) except *:
         raise SDLError(SDL_GetError())
 
 
+cdef void ttf_init() except *:
+    if TTF_Init() < 0:
+        raise SDLError(SDL_GetError())
+
+
 IF UNAME_SYSNAME == "Windows":
     cdef void set_main_ready():
         SDL_SetMainReady()
@@ -148,6 +174,10 @@ cdef void mix_quit() nogil:
     Mix_Quit()
 
 
+cdef void ttf_quit() nogil:
+    TTF_Quit()
+
+
 cdef void gl_set_attribute(SDL_GLattr attr, int value) except *:
     if SDL_GL_SetAttribute(attr, value) < 0:
         raise SDLError(SDL_GetError())
--- a/pytouhou/ui/gamerunner.pyx
+++ b/pytouhou/ui/gamerunner.pyx
@@ -28,6 +28,9 @@ from .background import BackgroundRender
 from .music import MusicPlayer, SFXPlayer, NullPlayer
 from .shaders.eosd import GameShader, BackgroundShader
 
+from collections import namedtuple
+Rect = namedtuple('Rect', 'x y w h')
+Color = namedtuple('Color', 'r g b a')
 
 logger = get_logger(__name__)
 
@@ -158,6 +161,7 @@ class GameRunner(GameRenderer):
             self.game.run_iter(keystate)
         if not self.skip:
             self.render_game()
+            self.render_text()
             self.render_interface()
         return True
 
@@ -175,6 +179,36 @@ class GameRunner(GameRenderer):
 
         glDisable(GL_SCISSOR_TEST)
 
+        if self.game.msg_runner:
+            rect = Rect(48, 368, 288, 48)
+            color1 = Color(0, 0, 0, 192)
+            color2 = Color(0, 0, 0, 128)
+            self.render_quads([rect], [(color1, color1, color2, color2)], 0)
+
+
+    def render_text(self):
+        if self.font_manager is None:
+            return
+
+        labels = [label for label in self.game.texts + self.game.native_texts if label is not None]
+        self.font_manager.load(labels)
+
+        black = Color(0, 0, 0, 255)
+
+        for label in labels:
+            if label is None:
+                continue
+
+            rect = Rect(label.x, label.y, label.width, label.height)
+            gradient = [Color(*color, a=label.alpha) for color in label.gradient]
+
+            if label.shadow:
+                shadow_rect = Rect(label.x + 1, label.y + 1, label.width, label.height)
+                shadow = [black._replace(a=label.alpha)] * 4
+                self.render_quads([shadow_rect, rect], [shadow, gradient], label.texture)
+            else:
+                self.render_quads([rect], [gradient], label.texture)
+
 
     def render_interface(self):
         elements = []
--- a/pytouhou/ui/renderer.pxd
+++ b/pytouhou/ui/renderer.pxd
@@ -16,3 +16,4 @@ cdef class Renderer:
     cdef PyObject *elements[640*3]
 
     cpdef render_elements(self, elements)
+    cpdef render_quads(self, rects, colors, texture)
--- a/pytouhou/ui/renderer.pyx
+++ b/pytouhou/ui/renderer.pyx
@@ -14,6 +14,7 @@
 
 from libc.stdlib cimport malloc, free
 from libc.string cimport memset
+from os.path import join
 
 from pytouhou.lib.opengl cimport \
          (glVertexPointer, glTexCoordPointer, glColorPointer,
@@ -24,9 +25,15 @@ from pytouhou.lib.opengl cimport \
           GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_TEXTURE_2D, GL_TRIANGLES,
           glGenBuffers)
 
+from pytouhou.lib.sdl import SDLError
+
 from pytouhou.game.element cimport Element
 from .sprite cimport get_sprite_rendering_data
-from .texture import TextureManager
+from .texture import TextureManager, FontManager
+
+from pytouhou.utils.helpers import get_logger
+
+logger = get_logger(__name__)
 
 
 DEF MAX_ELEMENTS = 640*4*3
@@ -59,6 +66,12 @@ cdef class Renderer:
 
     def __init__(self, resource_loader):
         self.texture_manager = TextureManager(resource_loader, self)
+        font_name = join(resource_loader.game_dir, 'font.ttf')
+        try:
+            self.font_manager = FontManager(font_name, 16, self)
+        except SDLError:
+            self.font_manager = None
+            logger.error('Font file “%s” not found, disabling text rendering altogether.', font_name)
 
         if not self.use_fixed_pipeline:
             glGenBuffers(1, &self.vbo)
@@ -149,3 +162,36 @@ cdef class Renderer:
 
         if not self.use_fixed_pipeline:
             glBindBuffer(GL_ARRAY_BUFFER, 0)
+
+
+    cpdef render_quads(self, rects, colors, texture):
+        # There is nothing that batch more than two quads on the same texture, currently.
+        cdef Vertex buf[8]
+        cdef unsigned short indices[12]
+        indices[:] = [0, 1, 2, 2, 3, 0, 4, 5, 6, 6, 7, 4]
+
+        length = len(rects)
+        assert length == len(colors)
+
+        glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
+
+        #TODO: find a way to use offsetof() instead of those ugly hardcoded values.
+        glVertexAttribPointer(0, 3, GL_INT, False, sizeof(Vertex), <void*>0)
+        glEnableVertexAttribArray(0)
+        glVertexAttribPointer(1, 2, GL_FLOAT, False, sizeof(Vertex), <void*>12)
+        glEnableVertexAttribArray(1)
+        glVertexAttribPointer(2, 4, GL_UNSIGNED_BYTE, True, sizeof(Vertex), <void*>20)
+        glEnableVertexAttribArray(2)
+
+        for i, r in enumerate(rects):
+            c1, c2, c3, c4 = colors[i]
+
+            buf[4*i] = Vertex(r.x, r.y, 0, 0, 0, c1.r, c1.g, c1.b, c1.a)
+            buf[4*i+1] = Vertex(r.x + r.w, r.y, 0, 1, 0, c2.r, c2.g, c2.b, c2.a)
+            buf[4*i+2] = Vertex(r.x + r.w, r.y + r.h, 0, 1, 1, c3.r, c3.g, c3.b, c3.a)
+            buf[4*i+3] = Vertex(r.x, r.y + r.h, 0, 0, 1, c4.r, c4.g, c4.b, c4.a)
+
+        glBufferData(GL_ARRAY_BUFFER, 4 * length * sizeof(Vertex), buf, GL_DYNAMIC_DRAW)
+        glBindTexture(GL_TEXTURE_2D, texture)
+        glDrawElements(GL_TRIANGLES, 6 * length, GL_UNSIGNED_SHORT, indices)
+        glBindBuffer(GL_ARRAY_BUFFER, 0)
--- a/pytouhou/ui/texture.pyx
+++ b/pytouhou/ui/texture.pyx
@@ -19,7 +19,7 @@ from pytouhou.lib.opengl cimport \
           glGenTextures, glBindTexture, glTexImage2D, GL_TEXTURE_2D, GLuint,
           glDeleteTextures)
 
-from pytouhou.lib.sdl cimport load_png, create_rgb_surface
+from pytouhou.lib.sdl cimport load_png, create_rgb_surface, Font
 from pytouhou.formats.thtx import Texture #TODO: perhaps define that elsewhere?
 
 import os
@@ -50,6 +50,33 @@ class TextureManager(object):
                 entry.texture.renderer = self.renderer
 
 
+cdef class FontManager:
+    cdef Font font
+    cdef object renderer
+
+    def __init__(self, fontname, fontsize=16, renderer=None):
+        self.font = Font(fontname, fontsize)
+        self.renderer = renderer
+
+
+    def load(self, label_list):
+        for label in label_list:
+            if not hasattr(label, 'texture'):
+                surface = self.font.render(label.text)
+                label.width, label.height = surface.surface.w, surface.surface.h
+
+                if label.align == 'center':
+                    label.x -= label.width // 2
+                elif label.align == 'right':
+                    label.x -= label.width
+                else:
+                    assert label.align == 'left'
+
+                texture = Texture(label.width, label.height, -4, surface.pixels)
+                label.texture = load_texture(texture)
+                label.texture.renderer = self.renderer
+
+
 cdef decode_png(loader, first_name, secondary_name):
     image_file = load_png(loader.get_file(os.path.basename(first_name)))
     width, height = image_file.surface.w, image_file.surface.h
--- a/pytouhou/ui/window.pyx
+++ b/pytouhou/ui/window.pyx
@@ -97,6 +97,7 @@ cdef class Window:
             sdl.set_main_ready()
         sdl.init(sdl.INIT_VIDEO)
         sdl.img_init(sdl.INIT_PNG)
+        sdl.ttf_init()
         if sound:
             sdl.mix_init(0)
 
@@ -169,5 +170,6 @@ cdef class Window:
     def __dealloc__(self):
         sdl.mix_close_audio()
         sdl.mix_quit()
+        sdl.ttf_quit()
         sdl.img_quit()
         sdl.quit()
--- a/pytouhou/vm/msgrunner.py
+++ b/pytouhou/vm/msgrunner.py
@@ -85,6 +85,7 @@ class MSGRunner(object):
         self._game.msg_runner = None
         self._game.msg_wait = False
         self.ended = True
+        self._game.texts = [None] * 4 + self._game.texts[4:]
 
 
     @instruction(0)
@@ -106,6 +107,15 @@ class MSGRunner(object):
             face.load(index)
 
 
+    @instruction(3)
+    def display_text(self, side, index, text):
+        if index == 0:
+            self._game.texts[0] = None
+            self._game.texts[1] = None
+        self._game.texts[index] = self._game.new_native_text((64, 372 + index * 24), text)
+        self._game.texts[index].set_timeout(-1, effect='fadeout', duration=15)
+
+
     @instruction(4)
     def pause(self, duration):
         if not (self.skipping and self.allow_skip):
@@ -129,6 +139,12 @@ class MSGRunner(object):
         self._game.music.play(track)
 
 
+    @instruction(8)
+    def display_description(self, side, index, text):
+        assert side == 1  # It shouldn’t crash when it’s something else.
+        self._game.texts[2 + index] = self._game.new_native_text((336, 320 + index * 18), text, align='right')
+
+
     @instruction(10)
     def freeze(self):
         self.frozen = True
--- a/setup.py
+++ b/setup.py
@@ -16,7 +16,7 @@ except ImportError:
 
 
 COMMAND = 'pkg-config'
-SDL_LIBRARIES = ['sdl2', 'SDL2_image', 'SDL2_mixer']
+SDL_LIBRARIES = ['sdl2', 'SDL2_image', 'SDL2_mixer', 'SDL2_ttf']
 
 packages = []
 extension_names = []