Шаг 7

Сегодняшний шаг будет продолжением Шага 6, а заодно станет первым шагом в следующей серии шагов, которую я пока назову GUI (графический интерфейс пользователя). В этой серии шагов будет сделана попытка разработать небольшой GUI используя SDL, OpenGL и Lua. По мере углубления в тему дальнейшее развитие получат С++ модули opengl и sdl, однако основную работу в GUI будут выполнять модули, написанные на Lua. Связано это с тем, что код на Lua, как мне кажется, много легче читать и понимать. Так же я собираюсь привлечь себе на помощь некоторое количество диаграмм.

Важно!
Весь код, представленный в следующих шагах, является, по большей части, демонстрационным, и в реальных системах быстро работать, скорее всего, не будет. Язык  Lua, хоть и является одним из самых быстрых скриптовых языков, все-таки не заточен на работу в run-time. Код и объяснения к нему предназначены быть скорее ориентирами, глядя на которые стоит разрабатывать собственную программу (за которую, я надеюсь, вам заплатят много-много денюжков :).

Ладно, хватит болтовни, переходим к делу.
Добавим в проект папку, в которой будут храниться скрипты Lua. Назовём её незатейливо scripts и поместим в корневую папку. Внутри папки scripts создадим папку modules, где будут храниться Lua модули для GUI. В модулях GUI мы будем симулировать объектно-ориентированную парадигму, то есть каждый модуль будет являться классом, объекты которого будут в дальнейшем создаваться в программе. Объекты классов будет создавать модуль Factory. Вот его код:

local base = _G

module('Factory')

function setBaseClass(class, baseClass)
    base.setmetatable(class, baseClass.meta)
end

function create(class, ...)
    local w = {}
    setBaseClass(w, class)
    w:construct(base.unpack(arg))
    return w
end

Что здесь происходит?
В переменной base сохраняется ссылка на глобальный контекст.
Объявляется модуль Factory и текущим становится контекст модуля.
В функции create() создаётся новая таблица w.
В функции setBaseClass() в новой таблице устанавливается метатаблица baseClass.meta, в которой определено поле __index.
Вызывается "метод" construct который, очевидно, должен содержаться в таблице class.
Возвращается таблица w.
Теперь напишем модуль, который сможет использовать модуль Factory. Это будет корневой класс иерархии классов GUI модуль Widget:

local base = _G

module('Widget')
meta = { __index = _M }

local Factory = base.require('Factory')

function new()
  return Factory.create(_M)
end

function construct(self)
  self.x = 0
  self.y = 0 
  self.w = 0
  self.h = 0
  self.color = {1, 1, 1}
end

function destroy(self)
end

Что мы здесь сделали?
Объявили модуль Widget
Создали таблицу meta, в которой поле __index ссылается  на таблицу _M, а это не что иное, как текущий модуль Widget.
Загрузили модуль Factory.
"Статическая" функция new() - с её помощью в программе будут создаваться экземпляры "класса" Widget.
"Конструктор" класса Widget, в котором происходит инициализация объекта класса.
"Деструктор"  класса Widget, к сожалению, не автоматический, нам его придётся вызывать в ручную.

Рассмотрим, как создать объект класса и что при этом произойдёт (допустим, некоторый скрипт zzz.lua):

local Widget = require('Widget')

local widget = Widget.new()

Сначала загружаем модуль Widget.
Вызываем функцию модуля Widget.new().
В ней происходит вызов Factory.create(_M).
В функцию Factory.create() первым параметром приходит ссылка на модуль Widget.
Далее в этой функции создаётся новая таблица w.
В функции Factory.setBaseClass(class = w, baseClass = Widget) новой таблице w устанавливается метатаблица Widget.meta (помните, что Widget.meta = {__index = Widget}?).
Вызываем конструктор Widget и в качестве self передаём ссылку на новую таблицу w. Вопрос: почему работает вызов w:construct()?
Кратко напоминаю изложенное в предыдущих шагах:
Возвращаем w.
Локальная переменная widget ссылается на объект класса Widget (уж коли взялись играть в ООП, то в дальнейшем я буду опускать стыдливые кавычки вокруг слов "класс", "метод" и т.п.).
Допустим, объект widget нам больше не нужен.

widget:destroy()
widget = nil

Все, нет больше никакого объекта widget.
Добавим в объект Widget несколько необходимых вызовов:

-- установить размер виджета
function setSize(self, w, h)
  self.w = w
  self.h = h
end

-- установить позицию виджета
function setPosition(self, x, y)
  self.x = x
  self.y = y
end

-- установить цвет виджета
function setColor(self, color)
  self.color = color
end

-- метод, в котором виджет может изменить своё внешнее представление
function update()
end

-- рисование виджета
function draw(self)
end

-- обработчик события нажатия кнопки мыши внутри виджета
-- Внимание! Координаты курсора приходят в экранных координатах от верхнего левого угла.
function onMouseDown(x, y, button)
end

У некоторых методов тело мы заполним чуть позже.
Разберёмся с рисованием. Для того, чтобы вызовы графической библиотеки не размазывать по всему коду, мы их соберём в один модуль Render:

local base = _G

module('Render')

local gl = base.require('opengl')

-- инициализация
function create()
  gl.Create()
end

-- очистить экран
function clear()
  gl.Clear()
end

-- цвет очистки экрана
function clearColor(r, g, b, a)
  gl.ClearColor(r, g, b, a)
end

-- установка вьюпорта
function viewport(x, y, w, h)
  gl.Viewport(x, y, w, h)
end

-- установка перспективной матрицы проекций
function perspective(fov, aspect, near, far)
  gl.MatrixMode(gl.PROJECTION)
  gl.LoadIdentity()
    gl.uPerspective(fov, aspect, near, far)
    gl.MatrixMode(gl.MODELVIEW)
end

-- установка параллельной матрицы проекций
function ortho(left, right, bottom, top, near, far)
  gl.MatrixMode(gl.PROJECTION)
  gl.LoadIdentity()
    gl.Ortho(left, right, bottom, top, near, far)
    gl.MatrixMode(gl.MODELVIEW)
end

-- модельная матрица умножается на матрицу смещения
function translate(x, y, z)
  gl.Translate(x, y, z)
end

-- модельная матрица умножается на матрицу поворота
function rotate(angle, x, y, z)
  gl.Rotate(angle, x, y, z)
end

-- сброс модельной матрицы в единичную матрицу
function identity()
  gl.LoadIdentity()
end

-- задать текущий цвет
function color(c)
  gl.Color(c[1], c[2], c[3], c[4])
end

-- вывод вершины
-- v - таблица
function vertex(v)
  local tex = v.tex
  if tex then
    gl.TexCoord2(tex[1], tex[2])
  end
  local norm = v.norm
  if norm then
    gl.Normal(norm[1], norm[2], norm[3])
  end
  gl.Vertex(v[1], v[2], v[3])
end

-- рисование линий
function lines(vertices)
  gl.Begin(gl.LINES)
  for i = 1, #vertices do
    vertex(vertices[i])
  end
  gl.End()
end

-- рисование треугольников
function triangles(vertices)
  gl.Begin(gl.TRIANGLES)
    for i = 1, #vertices do
      vertex(vertices[i])
    end
  gl.End()
end

-- рисование четырехугольников
function quads(vertices)
  gl.Begin(gl.QUADS)
    for i = 1, #vertices do
      vertex(vertices[i])
    end
  gl.End()
end

-- рисование прямоугольной рамки
function rect(x, y, w, h)
  gl.PushMatrix()
  translate(0.5, 0.5, 0) -- рисуем "между" пикселями
  gl.Begin(gl.LINE_LOOP)
    vertex({x, y, 0})
    vertex({x + w, y, 0})
    vertex({x + w, y + h, 0})   
    vertex({x, y + h, 0})
  gl.End()
  gl.PopMatrix()
end

-- рисование прямоугольника, заполненного текущим цветом
function fillrect(x, y, w, h)
  gl.Begin(gl.QUADS)
    vertex({x, y, 0})
    vertex({x + w, y, 0})
    vertex({x + w, y + h, 0})   
    vertex({x, y + h, 0})
  gl.End()
end

Вернемся ненадолго к модулю Widget и заполним тело метода draw:

-- рисование виджета
function draw(self)
  -- установить текущий цвет
  Render.color(self.color)
  -- заполнить прямоугольник текущим цветом
  Render.fillrect(self.x, self.y, self.w, self.h)
end

Теперь, собственно, сам модуль GUI, который свяжет между собой оконную библиотеку SDL, графическую библиотеку OpenGL и все то, что мы напридумывали с виджетами:

local base = _G

module('GUI')

local sdl = base.require('sdl')
local Render = base.require('Render')

-- инициализация
-- w, h - размеры окна
-- title - текст в заголовке окна
-- fullscreen - полноэкранный режим или нет
function create(w, h, title, fullscreen)
  Render.create()
  sdl.Create(w, h, title, fullscreen)
  -- цвет экрана черный
  Render.clearColor(0, 0, 0)
  -- изменение размера окна
  -- при запуске SDL не генерирует событие SDL_VIDEORESIZE
  -- делаем это самостоятельно
  resize(w, h)
  -- список всех виджетов
  widgets = {}
  -- текщее положение мыши
  mouseX = 0
  mouseY = 0
end

-- добавить виджет
function addWidget(w)
  base.table.insert(widgets, w)
end

-- размер окна изменен
function resize(w, h)
  Render.viewport(0, 0, w, h)
  Render.ortho(0, w, h, 0, -1, 1)
end

-- мышь переместили
function mouseMove(x, y)
  mouseX = x
  mouseY = y
end

-- проверка нахождения точки внутри виджета
function pointInWidget(x, y, widget)
  return x > widget.x and
         x < widget.x + widget.w and
         y > widget.y and
         y < widget.y + widget.h
end

-- нажали мышь
function mouseDown(button)
  for _, widget in base.ipairs(widgets) do
    if pointInWidget(mouseX, mouseY, widget) then
      widget:onMouseDown(mouseX, mouseY, button)
    end
  end
end

-- отпустили мышь
function mouseUp(x, y)
end

-- обновление GUI
function idle()
  for i = 1, #widgets do
    widgets[i]:update()
  end
end

-- рисование
function draw()
  -- очистка экрана
  Render.clear()
  -- сброс матрицы вида в единичную матрицу
  Render.identity()
  -- рисуем все виджеты
  for i = 1, #widgets do
    widgets[i]:draw()
  end
end

-- начало работы GUI
function run()
  sdl.Run()
end

И, наконец, проверка работоспособности написанного кода (guitest.lua):

local GUI = require('GUI')
local Widget = require('Widget')

local windowWidth = 400
local windowHeight = 300
local title = 'GUI test'
local fullscreen = false

GUI.create(windowWidth, windowHeight, title, fullscreen)

-- первый виджет
local w = Widget.new()
w:setSize(50, 100)
w:setPosition(10, 20)
w:setColor({1, 0, 0})
-- добавим в первый виджет поле deltaBlue,
-- значение которого будем изменять в методе update
w.deltaBlue = 0.001
-- обработчик нажатия мыши для первого виджета
function w:onMouseDown(x, y, button)
  print('Widget1 clicked!')
end

-- функция в которой первый виджет может изменить свое внутреннее состояние
-- и с успехом делает это!
function w:update()
  if 1 < self.color[3] or -1 > self.color[3] then
    self.deltaBlue = -self.deltaBlue
  end
  self.color[3] = self.color[3] + self.deltaBlue
end
GUI.addWidget(w)

-- второй виджет
w = Widget.new()
w:setSize(100, 50)
w:setPosition(110, 120)
w:setColor({0, 1, 0})
-- добавим во второй виджет поле deltaRed,
-- значение которого будем изменять в методе update
w.deltaRed = 0.001
-- обработчик нажатия мыши для второго виджета
function w:onMouseDown(x, y, button)
  print('Widget2 clicked!')
end

-- закрепляем успех первого виджета
function w:update()
  if 1 < self.color[1] or -1 > self.color[1] then
    self.deltaRed = -self.deltaRed
  end
  self.color[1] = self.color[1] + self.deltaRed
end
GUI.addWidget(w)

-- изменение размеров окна
-- приходит из модуля sdl
function resize(w, h)
  GUI.resize(w, h)
end

-- мышь переместили
-- приходит из модуля sdl
function mouseMove(x, y)
  GUI.mouseMove(x, y)
end

-- мышь нажали
-- приходит из модуля sdl
function mouseDown(button)
  GUI.mouseDown(button)
end

-- мышь отпустили
-- приходит из модуля sdl
function mouseUp(button)
  GUI.mouseUp(button) 
end

-- переменные для счетчика FPS(frames per second)
fpsStart = os.clock()
fpsCounter = 0

-- обновление GUI
function idle()
  GUI.idle()
  -- считаем FPS
  -- текущее время - количество секунд с начала старта программы
  local currTime = os.clock()
  local delta = currTime - fpsStart
  if 1 < delta then
    -- вывод в консоль
    print('FPS:', fpsCounter)
    -- сбрасываем переменные и начинаем отсчет кадров заново
    fpsCounter = 0
    fpsStart = currTime
  end
end

-- рисование GUI
function draw()
  GUI.draw()
  fpsCounter = fpsCounter + 1
end

-- Поехали!
GUI.run()

А вот и результат:




Для того, чтобы путь к модулям Lua не хранился внутри Lua-скрипта, вынесем эту информацию в отдельный .bat файл, благо Lua позволяет настраивать эти пути при помощи переменных среды. Файл назавем run.bat:

set LUA_CPATH=..\bin\lua-?.dll
set LUA_PATH=.\?.lua;.\modules\?.lua
..\bin\lua5.1.exe %1 %2 %3 %4 %5 %6 %7 %8 %9

Соответственно, файл guitest.lua можно запустить так:

run.bat guitest.lua

Краткое резюме: мы создали настоящую интерактивную программу! Кнопочки (ладно, почти кнопочки) красиво переливаются, а главное реагируют на наши действия. Ура нам.
Что дальше? Дальше мы ещё сильнее разорвём связь между сущностью виджета и его визуальным представлением, введя дополнительный уровень косвенности в виде темы оформления. Также мы создадим первое настоящее окно, которое можно будет таскать по экрану, и внутри него смогут располагаться другие виджеты.
До встречи!

Файлы урока можно взять здесь.

Назад


Hosted by uCoz