LÖVE (Love2D) 実践編・さめがめ:3.つながったコマを消す

(2018.9.9. 公開)

1. コマの消し方

 つながったコマを盤上から消し去る手順を考えてみる。まず、列ごとに上から落ちてくる処理。

 この処理を行うには、次のように進める。

 この処理でコマを移動する時、移動先のコマは常に「すでに処理済み」の位置なので、board 配列の中身を直接書き換えても構わない。(もし、未処理の位置に書き込む必要がある時は、移動先の配列を別に用意しておく必要がある。)

 コードは下のようになる。上の手順が理解できれば、コードを理解することは難しくないだろう。

-- === コマを下に詰める ===
function fill_down()
  for x = 0, xx - 1 do -- 各列について処理
    local y2 = yy - 1  -- 下から順に詰める
    for y1 = yy - 1, 0, -1 do
      local c = board[y1 * xx + x + 1]
      if c < 100 then
        -- (x, y1)のコマが生きていれば(x, y2)に移す
        if y2 ~= y1 then  -- y2 == y1 の時は同じ場所だから動かさなくていい
         board[y2 * xx + x + 1] = c
        end
        y2 = y2 - 1    -- これは y2 == y1 の時も必要
      end
    end
    -- 全部のコマを移し終わったら、それより上は0で埋める
    while y2 >= 0 do
      board[y2 * xx + x + 1] = 0
      y2 = y2 - 1
    end
  end
end

 次に、空いた列を左に詰める処理。列が空いているかどうかは、一番下にコマがあるかどうかを調べればよい。

 処理方法は、下に詰める場合とよく似ている。今度は、「移す前の列」を x1, 「移す先の列」を x2 で表す。

 コードは下の通り。

-- === 列を左に詰める ===
function fill_left()
  local x2 = 0
  for x1 = 0, xx - 1 do
    local c = board[(yy - 1) * xx + x1 + 1]
    if c > 0 then
      -- x1 列を x2 列に動かす
      if x2 ~= x1 then  -- x2 == x1 の時は同じ場所だから動かさなくていい
        for y = 0, yy - 1 do
          board[y * xx + x2 + 1] = board[y * xx + x1 + 1]
        end
      end
      x2 = x2 + 1       -- これは x2 == x1 の場合も必要
    end
  end
  -- 全部の列を詰め終わったら、それより右は0で埋める
  while x2 < xx do
    --  空にする
    for y = 0, yy - 1 do
      board[y * xx + x2 + 1] = 0
    end
    x2 = x2 + 1
  end
end

2. 盤面を書き換えて、得点をアップデート

 盤面の書き換えには、スプライトバッチの中身を書き換える。特定のスプライトだけを置き換える SpriteBatch:set() 関数を使う方法と、いったん中身をクリアして最初から作り直す方法がある。前者の方が負荷は少ないが、この処理は「コマを消した時」しか呼ばれないので、負荷をそれほど意識することはない。クリアして作り直す方法で書けば、最初に盤面を初期化する時にも流用できるので、この方法で書くことにする。スプライトの番号を board のキーと合わせるため、コマが存在しないところには「ダミーのスプライト(大きさを0にする)」を置く。

 また、残りコマ数の計算もここで行う。

-- === 盤面の更新 ===
function update_board()
  batch:clear()          --  スプライトバッチをクリア
  for i = 1, ntiles do   --  種類ごとの残数をクリア
    rest[i] = 0
  end
  num = 0                --  全体の残数をクリア
  for y = 0, yy - 1 do
    for x = 0, xx - 1 do
      local c = board[y * xx + x + 1] % 100 -- %100 は実は不要だが念のため
      if c == 0 then
        batch:add(quads[1], 0, 0, 0, 0, 0)  -- ダミー(表示しないスプライト)
      else
        batch:add(quads[c % 100], x * 32, (y + 1) * 32)
        rest[c] = rest[c] + 1               -- この種類の残数を+1
        num = num + 1                       -- 全体の残数を+1
      end
    end
  end
end

 結局、コマを消す処理は下のようになる。最初に、得点を加算する。N 個のコマを消した時の得点は (N–1)2 としている。次いで、コマを消して下に詰める・列を左に詰める作業を行い、盤面を更新して、マークを付け直す。

-- === コマを消す ===
function erase_tiles()
  -- === 消したコマの得点を加算 ===
  point = point + (cont - 1) * (cont - 1)
  -- === コマを消して詰める ===
  fill_down()     -- マークされたコマを消して下に詰める
  fill_left()     -- 列を左に詰める
  update_board()  -- 盤面を書き直す
  mark()          -- マークを付け直す
end

 cont というのは「連続したコマの数」で、mark() 関数で計算している。

3. リトライとギブアップの実装

 ゲームが進行できるようになると、「最初からやり直し」と「別の盤面でやり直し」の機能が欲しくなる。盤面初期化の処理を関数にして、「最初と同じ盤面を再現」および「新しい盤面を作成」の処理ができるようにしよう。

 「最初と同じ盤面を再現」するのに最初に思いつく方法は、「盤面のデータを別の配列にコピーしておく」ことだろう。もちろんこれでも良いのだが、もう1つのやり方として、「乱数のタネを保存しておく」方法もある。Love2D の乱数には「タネ」と呼ばれる数値があって、同じタネから始めると同じ乱数が生成される。

 タネは整数値で、love.math.setRandomSeed(seed) というコードで設定できる。よく使われるのは、「現在時刻」をタネとして使うものである。ただし、love.timer.getTime() が返す値をそのままタネとして使うと、1秒以内に2回「新しい盤面を作成」したとき、同じタネが使われてしまう。love.timer.getTime() の値を 1000 倍(ミリ秒単位)にすれば、まず同じタネが使われることはない。

 初期化を関数として実装すると、下のようになる。引数の retry が「真」なら、保存しておいたタネをセットして、同じ盤面が再現されるようにする。

-- === 盤面初期化 ===
function init_board(retry)
  if not retry then
    randomSeed = love.timer.getTime() * 1000  -- タネを新しく作る(ミリ秒単位の現在時刻)
  end
  love.math.setRandomSeed(randomSeed)    -- 保存してあるタネを使う
  for i = 1, xx * yy do                  -- xx*yy 回繰り返し
    board[i] = love.math.random(ntiles)  -- ランダムにコマを配置
  end
  update_board()           -- スプライトバッチ、残りコマ数を更新
  -- === 現在位置初期化 ===
  cx = 0
  cy = 0
  mark()  -- マークをつける
end

4. キー入力の実装

 love.keypressed() に追記して、リターンキー、"R", "N" の処理を付け加える。

function love.keypressed(key, scancode, isrepeat)
  local dx, dy = 0, 0
  if key == "right" then      -- 右矢印キーが押されている
    dx = 1
  elseif key == "left" then   -- 左矢印キーが押されている
    dx = -1
  elseif key == "up" then     -- 上矢印キーが押されている
    dy = -1
  elseif key == "down" then   -- 下矢印キーが押されている
    dy = 1
  elseif key == "return" then -- リターンキーが押されている
    if cont > 1 then          -- 2つ以上連続したコマがある
      erase_tiles()           -- コマを消す
    end
  elseif key == "r" then      -- "R" キーが押されている
    init_board(true)          -- 盤面の初期化(前回と同じ盤面)
    point = 0                 -- 得点をリセット
  elseif key == "n" then      -- "N" キーが押されている
    init_board()              -- 盤面の初期化(新しい画面)
    point = 0                 -- 得点をリセット
  end
  if dx ~= 0 or dy ~= 0 then
    -- 新しい位置を計算する。画面からはみ出したら、反対側から出てくる
    cx = (cx + dx) % xx
    cy = (cy + dy) % yy
    mark()  -- マークをつけ直す
  end
end

5. プログラム

 以上を合わせたのが次のプログラム。

-- サンプルプログラム 101-03 main.lua
-- fruits32.png, ipag.ttf が必要
function set_screen_size()
  width = 640                         -- ゲーム画面の横幅
  height = 480                        -- ゲーム画面の高さ
  love.window.setMode(width, height)  -- これが効くなら問題なし
  love.window.setTitle("さめがめ on LÖVE")
  swidth = love.graphics.getWidth()   -- 実画面の横幅
  sheight = love.graphics.getHeight() -- 実画面の高さ
  transx, transy = 0, 0
  scale = 1
  if swidth ~= width or sheight ~= height then
    -- setMode が効いてない場合
    if swidth / sheight > width / height then -- 実画面の方が横長
      scale = sheight / height          -- 縦方向で倍率を決める
      transx = math.floor((swidth - width * scale) / 2)  -- 空白の幅
      transy = 0
    else
      scale = swidth / width            -- 横方向で倍率を決める
      transx = 0
      transy = math.floor((sheight - height * scale) / 2) -- 空白の高さ
    end
  end
  love.graphics.setBackgroundColor(0.82, 0.82, 0.82) -- ゲーム画面の外は灰色の枠
end
  
function love.load()
  --  === 画面サイズ設定 ===
  set_screen_size()                 --  画面の大きさを設定
  xx = math.floor(width / 32)       --  盤面の幅
  yy = math.floor(height / 32) - 1  --  盤面の高さ
  love.keyboard.setKeyRepeat(true)  -- キーのオートリピートを有効にする
  -- === フォント指定 (IPAゴシック 16ポイント) ===
  font = love.graphics.newFont("ipag.ttf", 16)
  love.graphics.setFont(font)
  --  === 画像関連初期化 ===
  ntiles = 5                        --  コマの種類
  tiles = love.graphics.newImage("fruits32.png") -- コマ画像を読み込む
  wid0, high0 = tiles:getDimensions()            -- コマ画像のサイズ
  batch = love.graphics.newSpriteBatch(tiles)    -- スプライトバッチ
  quads = {}                        -- Quad を保持しておくテーブル
  for i = 1, ntiles do              -- Quad をコマの種類分だけ作る
    quads[i] = love.graphics.newQuad((i - 1) * 32, 0, 32, 32, wid0, high0)
  end
  --  === 初期盤面を作る ===
  board = {}                        --  盤面
  rest = {}                         --  種類ごとの残りコマ数
  init_board()                      --  最初の盤面を作る
  point = 0                         --  得点を0にする
end

-- === 盤面初期化 ===
function init_board(retry)
  if not retry then
    randomSeed = love.timer.getTime() * 1000  -- タネを新しく作る(ミリ秒単位の現在時刻)
  end
  love.math.setRandomSeed(randomSeed)    -- 保存してあるタネを使う
  for i = 1, xx * yy do                  -- xx*yy 回繰り返し
    board[i] = love.math.random(ntiles)  -- ランダムにコマを配置
  end
  update_board()           -- スプライトバッチ、残りコマ数を更新
  -- === 現在位置初期化 ===
  cx = 0
  cy = 0
  mark()  -- マークをつける
end

-- === 盤面の更新 ===
function update_board()
  batch:clear()          --  スプライトバッチをクリア
  for i = 1, ntiles do   --  種類ごとの残数をクリア
    rest[i] = 0
  end
  num = 0                --  全体の残数をクリア
  for y = 0, yy - 1 do
    for x = 0, xx - 1 do
      local c = board[y * xx + x + 1] % 100 -- %100 は実は不要だが念のため
      if c == 0 then
        batch:add(quads[1], 0, 0, 0, 0, 0)  -- ダミー(表示しないスプライト)
      else
        batch:add(quads[c % 100], x * 32, (y + 1) * 32)
        rest[c] = rest[c] + 1               -- この種類の残数を+1
        num = num + 1                       -- 全体の残数を+1
      end
    end
  end
end

-- (x, y) とつながっているコマをマークする(再帰呼び出し)
function mark_from_here(x, y)
  local c, n
  c = board[y * xx + x + 1]
  board[y * xx + x + 1] = c + 100
  n = 1
  if x > 0 and board[y * xx + x] == c then
    -- 左のマスに同じコマがある
    n = n + mark_from_here(x - 1, y)
  end
  if x < xx - 1 and board[y * xx + x + 2] == c then
    -- 右のマスに同じコマがある
    n = n + mark_from_here(x + 1, y)
  end
  if y > 0 and board[(y - 1) * xx + x + 1] == c then
    -- 上のマスに同じコマがある
    n = n + mark_from_here(x, y - 1)
  end
  if y < yy - 1 and board[(y + 1) * xx + x + 1] == c then
    -- 下のマスに同じコマがある
    n = n + mark_from_here(x, y + 1)
  end
  return n
end

-- (cx, cy) とつながっているコマをマークする
function mark()
  -- === すべてのマークを外す ===
  for i = 1, xx * yy do
    board[i] = board[i] % 100
  end
  -- === (cx, cy) にコマがあれば、そことつながっているコマをマークする ===
  if board[cy * xx + cx + 1] ~= 0 then
    cont = mark_from_here(cx, cy)  -- つながっているコマの数
  else
    cont = 0
  end
end
  
-- マークされているコマの位置を塗る
function show_mark()
  local r = 8
  for x = 0, xx - 1 do
    for y = 0, yy - 1 do
      local i = y * xx + x + 1
      if board[i] > 100 then
        local x1, y1 = x * 32, (y + 1) * 32
        love.graphics.rectangle("fill", x1, y1, 32, 32, r, r)
        if x > 0 and board[i - 1] > 100 then
          love.graphics.rectangle("fill", x1, y1, r, 32)
        end
        if x < xx - 1 and board[i + 1] > 100 then
          love.graphics.rectangle("fill", x1 + 32 - r, y1, r, 32)
        end
        if y > 0 and board[i - xx] > 100 then
          love.graphics.rectangle("fill", x1, y1, 32, r)
        end
        if y < yy - 1 and board[i + xx] > 100 then
          love.graphics.rectangle("fill", x1, y1 + 32 - r, 32, r)
        end
      end
    end
  end
end

-- === コマを下に詰める ===
function fill_down()
  for x = 0, xx - 1 do -- 各列について処理
    local y2 = yy - 1  -- 下から順に詰める
    for y1 = yy - 1, 0, -1 do
      local c = board[y1 * xx + x + 1]
      if c < 100 then
        -- (x, y1)のコマが生きていれば(x, y2)に移す
        if y2 ~= y1 then  -- y2 == y1 の時は同じ場所だから動かさなくていい
         board[y2 * xx + x + 1] = c
        end
        y2 = y2 - 1    -- これは y2 == y1 の時も必要
      end
    end
    -- 全部のコマを移し終わったら、それより上は0で埋める
    while y2 >= 0 do
      board[y2 * xx + x + 1] = 0
      y2 = y2 - 1
    end
  end
end

-- === 列を左に詰める ===
function fill_left()
  local x2 = 0
  for x1 = 0, xx - 1 do
    local c = board[(yy - 1) * xx + x1 + 1]
    if c > 0 then
      -- x1 列を x2 列に動かす
      if x2 ~= x1 then  -- x2 == x1 の時は同じ場所だから動かさなくていい
        for y = 0, yy - 1 do
          board[y * xx + x2 + 1] = board[y * xx + x1 + 1]
        end
      end
      x2 = x2 + 1       -- これは x2 == x1 の場合も必要
    end
  end
  -- 全部の列を詰め終わったら、それより右は0で埋める
  while x2 < xx do
    --  空にする
    for y = 0, yy - 1 do
      board[y * xx + x2 + 1] = 0
    end
    x2 = x2 + 1
  end
end

-- === コマを消す ===
function erase_tiles()
  -- === 消したコマの得点を加算 ===
  point = point + (cont - 1) * (cont - 1)
  -- === コマを消して詰める ===
  fill_down()     -- マークされたコマを消して下に詰める
  fill_left()     -- 列を左に詰める
  update_board()  -- 盤面を書き直す
  mark()          -- マークを付け直す
end

function love.keypressed(key, scancode, isrepeat)
  local dx, dy = 0, 0
  if key == "right" then      -- 右矢印キーが押されている
    dx = 1
  elseif key == "left" then   -- 左矢印キーが押されている
    dx = -1
  elseif key == "up" then     -- 上矢印キーが押されている
    dy = -1
  elseif key == "down" then   -- 下矢印キーが押されている
    dy = 1
  elseif key == "return" then -- リターンキーが押されている
    if cont > 1 then          -- 2つ以上連続したコマがある
      erase_tiles()           -- コマを消す
    end
  elseif key == "r" then      -- "R" キーが押されている
    init_board(true)          -- 盤面の初期化(前回と同じ盤面)
    point = 0                 -- 得点をリセット
  elseif key == "n" then      -- "N" キーが押されている
    init_board()              -- 盤面の初期化(新しい画面)
    point = 0                 -- 得点をリセット
  end
  if dx ~= 0 or dy ~= 0 then
    -- 新しい位置を計算する。画面からはみ出したら、反対側から出てくる
    cx = (cx + dx) % xx
    cy = (cy + dy) % yy
    mark()  -- マークをつけ直す
  end
end
  
function love.draw()
  -- === 描画範囲を設定 ===
  love.graphics.setScissor(transx, transy, width * scale, height * scale)
  love.graphics.translate(transx, transy) -- 原点移動
  love.graphics.scale(scale, scale) -- 拡大率を設定
  -- === 盤面を塗りつぶす ===
  love.graphics.setColor(0.39, 0.42, 0.92)
  love.graphics.rectangle("fill", 0, 0, width, 32)  -- 点数、残りコマ数など
  love.graphics.setColor(0.47, 0.50, 1.00)
  love.graphics.rectangle("fill", 0, 32, width, height - 32)  -- 盤面
  -- === スコア・残りコマ数など表示 ===
  love.graphics.setColor(1, 1, 1)
  love.graphics.print(string.format("スコア %-d", point), 4, 8)
  love.graphics.print(string.format("残り %-d", num), 120, 8)
  for i = 1, ntiles do
    love.graphics.draw(tiles, quads[i], 140 + i * 60, 0)
    love.graphics.print(string.format("%-d", rest[i]), 140 + 36 + i * 60, 8)
  end
  love.graphics.print("[R]etry [N]ew", 220 + ntiles * 60, 8)
  -- === マークの表示 ===
  love.graphics.setColor(0.39, 1, 1)  -- 水色
  show_mark()
  -- === 現在位置の表示 ===
  love.graphics.setColor(1, 1, 1)  -- 白色
  love.graphics.rectangle("fill", cx * 32, (cy + 1) * 32, 32, 32, 8, 8) -- 角丸四角形を塗りつぶす
  -- === コマの表示 ===
  love.graphics.draw(batch, 0, 0)
  -- === 描画範囲をリセット ===
  love.graphics.setScissor()
end

 一応ゲームとして成立するようになった。

目次