読者です 読者をやめる 読者になる 読者になる

Minecraftとタートルと僕

PCゲームMinecraftのMOD「ComputerCraft」の情報を集めたニッチなブログです。

オブジェクト指向でどうやってAPIを作るか(h2touchpanel APIの詳細解説)

チュートリアル Lua ComputerCraft Minecraft API

はじめに

オブジェクト指向プログラミングでComputerCraftのAPIを作成するという、マニアックな企画です。
さて、どのくらいの人が興味を持ち、ついてこれるのでしょうか……。
f:id:hevohevo:20140209205750p:plain:w300

本APIの作成方針

ComputerCraftにおいてAPIを作るときの原則は以下の通りです。

  • 一種類のAPIを一つのファイルにまとめて書く。
    • h2touchpanel APIならば、ファイル名「h2touchpanel」として保存
  • APIファイルの作り方
    • APIとして使わせたくない(隠ぺいしたい)関数・変数はローカル宣言しておく
    • APIとして提供する関数・変数はこのファイルの中でグローバルでなければならない
  • APIの使い方
    • このファイルをAPIとして読み込むときには、os.loadAPI(ファイル名)関数を使う
    • h2touchpanel.myfunc() のように、テーブル"h2touchpanel"に入っている関数・変数を「.」ドット記法で使用する。

これを踏まえて、h2touchpanelのプログラミング方針は以下のとおり。
(これが一般的な方針かどうかはわかりません。あくまで、このAPIの方針です)

  • オブジェクト指向プログラミング
  • オブジェクトを表現する以下のテーブルは原則としてlocal宣言
    • Coordinate, Rectangle, Button, Panel
  • 唯一、Panel.new()のラッパーであるmakePanel()関数のみグローバル化しておく。
    • つまり、h2touchpanel APIが提供する関数は、makePanel()のみ。
    • あとは、作成したPanelオブジェクトに対するメッセージ送信(「:」コロン記法)によりオブジェクトに用意されているメソッドを利用することで、様々な機能を使えるようにする。
オブジェクト指向APIの使用例
local monitor = peripheral.wrap("top")

 -- 空ボタン3x3=9個から構成されるタッチボタンパネルを作成
local panel_obj1 = h2touchpanel.makePanel({}, 3, 3, monitor)

panel_obj1:draw() -- パネルを画面表示
panel_obj1:pp() -- パネルの情報をprint()

local button_obj1 = panel_obj1:pullButtonPushEvent() -- ボタンが押されるのを待つ
button_obj1:draw(monitor) -- ボタンを画面表示

これにより、APIを利用する側のプログラムがオブジェクト指向プログラミングであろうが、関数型プログラミングであろうが(それほど)違和感なくこのAPIを利用できます。

作成したオブジェクトの説明

なお、このAPIで作成したオブジェクトについては以下のとおり。

  • Coordinate: 座標オブジェクト。
    • 座標(x,y)を取扱う。また、2座標間の幅や丈、距離などを計算するメソッドを提供
  • Rectangle: 四角形オブジェクト。
    • 左上座標、右下座標、塗りつぶし色を取扱う。また、四角形を描画するdraw()メソッドも提供。
  • Button: ボタンオブジェクト。
    • 上記Rectangleオブジェクトを継承する。Rectangle+中央にラベル表記。プロパティとして、文字色(fgColor)や実行関数(cmd)なども保持。
    • ボタンを描画するdraw()や、ボタンに設定されている関数を実行するevalCmd()も提供。
  • Panel: パネルオブジェクト。
    • Buttonオブジェクトを複数保持。Panelオブジェクト生成時(new)に、「ボタン設定テーブル」「ボタンの列数col」「ボタンの行数row」「モニター情報」を与えることにより、モニター内に適切にボタンを作成・配置する。
      • この「適切に」の部分がこのプログラムの一番面倒なところでした。最長関数(泣)。
    • 重要メソッドとして、pullButtonPushEvent()がある。
      • "button_push"というイベント名(文字列)を返値1、押されたボタンオブジェクトを返値2として返す。

以降は、ソースコードの具体的な解説ですが、コード量が多すぎるので細かな説明はしていません。
おもにそのコードの意図の解説です。

プログラムの説明

ソースコード全体

http://pastebin.com/eK9BW600

  • 0.1d, 2014/02/17, Panel Optionの整備、モニターのscaleを指定可能
  • 0.1c, 2014/02/11, bugfix(パネル作成時にoptを省略したときの不具合修正)
  • 0.1b, 2014/02/11, API仕様変更(cmdにfunctionを指定)
  • 0.1a, 2014/02/10, 公開

座標(Coordinate)オブジェクトのソースコード

-- Coordinate Object
local Coordinate = {}
Coordinate.new = function(_x,_y)
  local obj = {}
  obj.x = _x
  obj.y = _y
  return setmetatable(obj,{__index = Coordinate})
end
 
Coordinate.pp = function(self) print("(",self.x,',',self.y,")") end
Coordinate.width = function(startP, goalP) return math.abs(goalP.x - startP.x)+1 end
Coordinate.height = function(startP, goalP) return math.abs(goalP.y - startP.y)+1 end
Coordinate.midpoint = function(startP, goalP, floor_flag)
  local x = startP.x + startP:width(goalP)/2
  local y = startP.y + startP:height(goalP)/2
  if floor_flag then
    return Coordinate.new(math.floor(x),math.floor(y))
  else
    return Coordinate.new(x,y)
  end
end
  • 最初に座標オブジェクト生成関数(new)の定義
    • 生成時の引数として、x, y
    • このオブジェクトはプロパティとして、x座標とy座標の値を保持しています。
    • オブジェクト生成は次のように、「local coordinate = Coordinate.new(1,1)」
  • このオブジェクトのメソッドとして、以下を用意しています。
    • coordinate:pp()
      • 座標情報をprint()する
    • coordinate1:width(coordinate2)
      • coordinate1とcoordinate2間の横幅を計算。(2,2)と(4,4)間の横幅は3(X座標の差+1)。
    • coordinate1:height(coordinate2)
      • coordinate1とcoordinate2間の高さ(丈?)を計算。(2,2)と(4,4)間の高さは3(Y座標の差+1)。
    • coordinate1:midpoint(coodinate2)
      • coordinate1とcoordinate2の中間地点座標オブジェクトを返す。"floor"フラグを渡すと小数点以下切り捨て。

四角形(Rectangle)オブジェクトのソースコード

-- Rectangle Object
local Rectangle= {}
Rectangle.new = function(_startX, _startY, _goalX, _goalY, _color)
  local obj = {}
  obj.start = Coordinate.new(_startX, _startY)
  obj.goal = Coordinate.new(_goalX, _goalY)
  obj.bgColor = _color
  return setmetatable(obj,{__index = Rectangle})
end
 
Rectangle.draw = function(self,mon)
  mon.setBackgroundColor(self.bgcolor)
  for y=self.start.y, self.goal.y do
    mon.setCursorPos(self.start.x, y)
    for x=self.start.x, self.goal.x do
      mon.write(" ")
    end
  end
end
  • 四角形オブジェクト生成関数(new)の定義
    • プロパティ
      • start、左上の点(Coordinateオブジェクト)
      • goal、右下の点(Coordinateオブジェクト)
      • bgColor、塗りつぶし色
  • メソッド
    • rectangle1:draw(mon)
      • 指定した、モニターあるいはターミナル画面に四角形を描画する。
      • 引数としてモニター関数テーブル(peripheral.wrap("top")など)や、ターミナル画面関数オブジェクト(term)を与える。

ボタン(Button)オブジェクトのソースコード

-- Button Object
local Button = {}
Button.new = function(_name, _cmd, _startX, _startY, _goalX, _goalY, _fgColor, _bgcolor)
  local obj = Rectangle.new(_startX, _startY, _goalX, _goalY, _bgColor)
  obj.name = _name
  obj.fgColor = _fgColor
  obj.cmd = _cmd
  obj.toggle_flag = false
  return setmetatable(obj,{__index = Button})
end
 
Button.pp = function(self)
  local str_cmd = self.cmd
  if type(self.cmd) == "function" then
    str_cmd = "a function"
  elseif not self.cmd then
    str_cmd = "do-nothing"
  end
  print(string.format("%s: (%d,%d)-(%d,%d), %d, %s",
    self.name, self.start.x, self.start.y, self.goal.x, self.goal.y, self.bgcolor, str_cmd)) 
end
 
Button.draw = function(self,mon)
  Rectangle.draw(self, mon)-- draw a rectangle
  -- draw button label into the rectangle
  local bWidth = self.start:width(self.goal)
  local bHeight = self.start:height(self.goal)
  local name = self.name
  local label = string.sub(self.name, 1, bWidth)
  local length = string.len(label)
  local midpoint = self.start:midpoint(self.goal)    
  mon.setCursorPos(math.floor(midpoint.x - length/2), math.floor(midpoint.y))
  mon.write(label)
end
 
Button.isWithin = function(self, point)
  local _within = function(n,start,goal)
    return n>=start and n<=goal
  end
  return _within(point.x, self.start.x, self.goal.x) and _within(point.y, self.start.y, self.goal.y)
end
 
Button.evalCmd = function(self)
  if not self.cmd or self.cmd=="" then
    return false
  elseif type(self.cmd) == "string" then
    return assert(loadstring(self.cmd), "invalid cmd: this string is invalid.")()
  elseif type(self.cmd) == "function" then
    return self.cmd()
  else
    return assert(false, "invalid cmd: cmd is function or function-string")
  end
end

Button.run = Button.evalCmd
  • Button = Rectangle + label + cmd みたいなもの
  • 四角形を描画して、ボタン名(name)を四角形の中央に表記するのがdraw()
  • button:isWithin(coordinate)は、与えた座標coordinateが、buttonの範囲内かどうかを返す。boolean。
  • button:evalCmd()は、ボタンに設定された関数を実行(eval)する。
  • button:run()は、evalCmd()の別名関数。

パネル(Panel)オブジェクトのソースコード

-- Panel Object
local Panel = {}
Panel._makeButtons = function(_bCfg, _col, _row, _mon, opt)
  -- 長すぎるので省略
end
 
Panel.new = function(_bCfg, _col, _row, _mon, opt)
  if _mon.setTextScale then _mon.setTextScale(0.5) end
  local obj= {}
  obj.col=_col
  obj.row=_row
  obj.total = _col*_row
  obj.mon = _mon
 
  obj.btns= Panel._makeButtons(_bCfg, _col, _row, _mon, opt)
 
  return setmetatable(obj,{__index = Panel})
end
 
Panel.pp = function(self)
  print("Panel: ",self.col,'x',self.row)
  print("   Btns: ",#self.btns)
 
  for i,b in ipairs(self.btns) do
    b:pp()
  end
end
 
Panel.draw = function(self)
  self.mon.setBackgroundColor(colors.gray)
  self.mon.clear()
  for i,b in ipairs(self.btns) do
    b:draw(self.mon)
  end
end
 
Panel.drawPushedButtonEffect = function(self, btn, _sec)
  self.mon.setBackgroundColor(colors.gray)
  self.mon.clear()
  btn:draw(self.mon)
  sleep(_sec or 1)
  self:draw()
end
 
Panel.pullButtonPushEvent = function(self, mon_side)
  local pushed_btn = false
  local whichButton = function(btns,x,y)
    for i,b in ipairs(btns) do
      if b:isWithin(Coordinate.new(x,y)) then
        pushed_btn = b
        break
      end
    end
  end
 
  repeat
    if self.mon.setTextScale then -- when self.mon is advanced_monitor
      local event, side, x, y = os.pullEvent("monitor_touch")
      if not mon_side or mon_side == side  then
        whichButton(self.btns, x, y)
      end
    else -- when self.mon is term
      local event, mouse, x, y = os.pullEvent("mouse_click")
      whichButton(self.btns, x, y)
    end
  until  pushed_btn
 
  return "button_push", pushed_btn
end
  • _makeButtons()はPanel.new()内で使用するサブ関数で、パネル内に適切にボタン配置できるようボタン群を生成する。この部分のデバッグがこのAPI作成時間の約半分を占めている(泣)。
    • 基本的に、各ボタンの幅・丈は、モニタサイズを均等割りして計算。余った余白部分はmarginとして、端にあるボタンに加える。つまり、端にあるボタンが大きくなるというデザイン。
    • ボタンの背景色は、色パターンを指定する。{colors.blue, colors.yellow}と指定したら、青・黄・青・黄……と繰り返す。
  • Panel.new()メソッド設計のポイントは、必須の引数を最小限にすることで、できるだけ簡単にパネルを作成できるようにすること。必須以外の引数はオプション(opt)としてテーブルにまとめて与える。オプションなので、当然ながら省略可能。
    • opt={leftPos=1, topPos=1, rightPos=10, bottomPos=10, fgColor=colors.white, bgColor={colors.blue, colors.yellow}, scale=0.5}
      • タッチパネル表示範囲の指定: leftPos, topPos, rightPos, bottomPos
      • ボタンラベルの色: fgColor
      • 背景色パターン: bgColor
      • モニターのsetTextScale値:scale
  • 用意したメソッドは、以下のとおり。
    • draw(): パネル描画
    • drawPushedButtonEffect(): タッチパネルなので押したかどうかのフィードバックがないと使いづらい。押されたら、そのボタンだけを1秒(引数で指定可能)だけ強調表示する。
    • pullButtonPushEvent(): タッチパネルボタンがおされたら、"button_push"イベントとして、押されたボタンオブジェクトを返す。
      • タッチパネルがモニターならば、"monitor_touch"イベントを利用する。ターミナル画面ならば、"mouse_click"イベントを利用する。

プログラムの最後に、提供する関数のラッパーを定義

-- ########################
-- API Functions
function makePanel(bCnfg, col, row, mon, opt)
  return Panel.new(bCnfg, col, row, mon, opt)
end

おわりに

オブジェクト指向プログラミングだと、そうでないスタイルで書くよりもコード量は一気に増えますね。
でも、オブジェクト指向のスタイルが身についているなら、プログラムの概要がわかりやすく、機能追加も簡単だと思います。

ただし、プログラミング初心者に今回のコードを見せて、いきなり理解しろというのも無理なお話。
あくまで、オブジェクト指向プログラミングを理解している人向けですよね……。

今後の方針としては、長く複雑になりがちなプログラムはオブジェクト指向でプログラミングし、初心者向けの解説を目的としたプログラム、特に短めで見通しの良いプログラムについては関数型のプログラミングを心がけたいと思います。