【拡張編2】神経衰弱ゲーム:ブラウザで動くPython実装 Brython

前回までのコードを基に、難易度の選択機能と点数機能を付加し、なおかつ運ゲー要素を排除しました。一度開けた既知のアイテムを次に間違って開くと1点減点されます。既知のアイテムに対して当てずっぽうで未知のアイテムを開きにいった場合は2点の減点です。当てずっぽうではマッチしないようにこっそりと内部操作を行っています。ルールをちょっと変えるだけで、そこそこ頭を使う脳トレゲームになりますね。

難易度(選択してください)持ち点ハイスコア
score

    解説

    前回に引き続き、BrythonからはjQueryをjqという名前で利用しています。

    まずは、ゲームの表示や操作が行われる部分のHTMLソースです。これらの要素にjq('#score').text(score)などとしてアクセスします。

    <table id="infoTable">
        <tr>
          <th>難易度(選択してください)</th><th>持ち点</th><th>ハイスコア</th>
        </tr>
        <tr>
          <td  align=center ><select id="gameLevel"></select></td>
          <td id="score" align="right"></td>
          <td id="hiScore" align="right">--</td>
        </tr>
        <tr><td colspan=3><ul id="item"></ul></td></tr>
    </table>

    難易度選択部分

    2~8までの難易度に対応させました。初期状態は難易度4(4種類のアイテムによる神経衰弱)です。
    前回までのコードは8種類のアイテムを使った決め打ち方式でしたので、コードを流用しつつもフレキシブルに対応させる必要があります。
    まずは、HTML上で難易度を選択させるため、タグにgameLevelというIDを設定し、

    # 使用可能なアイテムの種類の最大数
    MAX_N_KIND = 8
    # デフォルト難易度
    DEFAULT_GAME_LEVEL = 4
    ~略~
    for i in range(MAX_N_KIND, 1, -1):
      rest = ' selected' if i==DEFAULT_GAME_LEVEL else ''
      jq('#gameLevel').append(f'<option value={i}{rest}>Level {i}</option>')

    のように要素を難易度の数だけ追加します。BrythonはPython3の実装なので、ちゃーんとf-string(フォーマット済文字列リテラル)も使えます。
    なお、デフォルト難易度の場合は<option ... selected>として初期状態で表示されるようにします。

    ユーザが選択リストを操作して難易度を変更した際に呼ばれるコールバック関数は、デコレータを使って

    @bind(jq('#gameLevel'), 'change')
    def onLevelChange(ev):
      # 難易度の取得
      level = int(ev.currentTarget.value)
      # 再初期化
      initialize_items(level)

    のように実に簡潔に書くことができます。この見た目のスッキリ感だけでも、AltJS(代替JavaScript)であるBrythonでWEBフロントエンドを書く意義があるというものではないでしょうか。

    点数

    難易度に応じた持ち点方式と、ハイスコア記録機能を導入します。
    持ち点は難易度が高くなるに従ってより多く与えられます。決め方はだいたいこんなものだろうという適当さです(笑)。おそらく、もっと適切な決め方があるでしょう。

    def initial_score(level):
      return level-1 + level//4

    ペナルティによって持ち点が減点されたりハイスコアに変化があった場合には、jq('#score').text(score)jq('#hiScore').text(hiScore) のようにjQueryを経由して対象の要素の内容を書き換えます。textメソッドに渡す内容はstr(score)のように文字列化して渡さなくてもOKのようです。このあたり、見た目はPythonだけどJavaScriptの匂いがなんとなくしますね。

    「運ゲー」要素の排除

    トランプを使って遊ぶオーソドックスな神経衰弱では、まったく未知の札を2枚引いてそれが当たることがあります。あるいは最初に既知の1枚をめくっておいて、まだめくられていない未知の札を運頼みでめくって当たることもあります。前回までの神経衰弱ゲームはこのタイプのゲームでした。
    偶然に頼ってうまくいくこともある俗に言う「運ゲー」の要素も神経衰弱の面白みではありますが、アイテムが最大8種類しかないこのブラウザ上の神経衰弱ゲームでは今回、運ゲーの要素を排除して短期記憶力の試されるちょっと難しい脳トレゲームに改造します。

    未知のアイテムが偶然マッチすることを回避

    運要素を排除する方策として、まだ開かれていないアイテムが偶然マッチしてしまう(当てずっぽうでマッチしてしまう)場合には、裏方でアイテムの並びをこっそり変えてマッチしないようにしてしまいます。
    担当するのは次の関数です。未知のアイテムがマッチしていれば、まだ開かれていない別の種類のアイテムとの入れ替え(スワッピング)を試みます。最後まで開けられずに残ったアイテムだった場合、入れ替える相手がいませんので、その場合はそのままにしておきます。

    def manipulate_if_needed(pos1,pos2):
        if indexes[pos1] != indexes[pos2]:
          return
        complete = len(cleared) == N_ITEM-2
        # 二枚目が当てずっぽうの場合には、必ず不正解になるよう操作する
        if not complete and trials[pos2]==1:
          pos3 = None
          for i in range(N_ITEM):
            if not trials[i]:
              pos3 = i
              break
          # アイテムを入れ替え
          if pos3 is not None:
            global indexes
            indexes[pos2],indexes[pos3] = indexes[pos3],indexes[pos2]

    記憶違いと当てずっぽうにはペナルティで対応

    一つ目に既知のアイテムを開き、2つ目にまったく未知のアイテムを開いた場合、上記の内部操作によって絶対にマッチすることはありません。でもそれだけでは面白くないので、当てずっぽう行為に対しては2点減点という厳しいペナルティを課すことにします。
    二枚目に既知のアイテムを開いてマッチしなかった場合は、記憶違いに対するペナルティとして1点を減点します。
    このルールによって、開いたアイテムの種類だけでなくまだ開けていないアイテムの場所も同時に記憶しなければならなくなります。これ、意外と頭を使いますよ。

    def check():
      global selected, cleared
      pos1,pos2 = selected[0],selected[1]
      if indexes[pos1] == indexes[pos2]:
        ~略~
      else:
        #  未知のアイテムと既知のアイテムを1つずつ開けた場合
        if not trials[pos1]==trials[pos2]==1:   
          if trials[pos1]!=1 and trials[pos2]==1:
            # 二枚目を完全な当てずっぽうで引いた場合のペナルティは2点
            increase_penalties(2)
          else:
            # そうでなければペナルティは1点
            increase_penalties(1)
        ~略~

    全コード

    以下、HTML内に記述されたスクリプト部分の全体です。前回のコードをほぼそのまま流用しつつ、大きく機能を拡張したので、やや煩雑な印象は否めません。でも、プログラムはいじり倒してなんぼ!の世界でもありますよね。

    <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
    <script type="text/javascript" src="https://cdn.rawgit.com/brython-dev/brython/master/www/src/brython.js"></script>
    <script type="text/javascript" src="https://cdn.rawgit.com/brython-dev/brython/master/www/src/brython_stdlib.js"></script>
    
    <script type="text/python">
    from browser import window,bind,timer,alert
    from random import shuffle
    
    jq = window.jQuery
    
    MAX_N_KIND = 8
    DEFAULT_GAME_LEVEL = 4
    N_KIND,N_ITEM = None,None
    score,hiScore = None,None
    indexes, trials, selected, cleared = None,None,None,None
    ignore_select = True
    
    # 選択リストに難易度のオプションを必要な数だけ追加
    for i in range(MAX_N_KIND, 1, -1):
      rest = " selected" if i==DEFAULT_GAME_LEVEL else ""
      jq('#gameLevel').append(f'<option value={i}{rest}>Level {i}</option>')
    
    def initial_score(level):
      return level-1 + level//4
    
    def reset(level):
      global N_KIND,N_ITEM,indexes,trials,score
      N_KIND = level
      N_ITEM = N_KIND*2
      score = initial_score(N_KIND)
      jq('#score').text(score)
      indexes = list(range(1,N_KIND+1)) * 2
      shuffle(indexes)
      trials = [0] * N_ITEM
      global cleared,selected #,ignore_select
      cleared,selected = [],[]
      #ignore_select = False
    
      jq('#item').empty()
      for i in range(N_ITEM):
        jq('#item').append('<li><img src="/wp/wp-content/uploads/2019/03/icon-128px-hatena.jpeg"></li>')
    
      register_local_callback()
    
    # 当てずっぽうで当てさせないための内部操作  
    def manipulate_if_needed(pos1,pos2):
        if indexes[pos1] != indexes[pos2]:
          return
        complete = len(cleared) == N_ITEM-2
        # 二枚目が当てずっぽうの場合には、必ず不正解になるよう操作する
        if not complete and trials[pos2]==1:
          pos3 = None
          for i in range(N_ITEM):
            if not trials[i]:
              pos3 = i
              break
          # アイテムを入れ替え
          if pos3 is not None:
            global indexes
            indexes[pos2],indexes[pos3] = indexes[pos3],indexes[pos2]
    
    # アイテムを消去して再配置する度に必要なコールバック関数の登録処理
    def register_local_callback():
      @bind(jq('#item li'), 'mousedown')
      def onSelect(ev):
        if ignore_select: return
        idx = jq('#item li').index(ev.currentTarget)
        if len(selected) < 2 and idx not in selected and idx not in cleared:
          selected.append(idx)
          trials[idx] += 1
          if len(selected) == 2:
            manipulate_if_needed(*selected)
          open(idx)
          if len(selected) == 2:
            check()
    
    def initialize_items(level):
      reset(level)
      for idx in range(N_ITEM):
        animate(idx, ({'zoom':'10%','opacity':0},1))
      splash()()
    
    @bind(jq('#gameLevel'), 'change')
    def onLevelChange(ev):
      level = int(ev.currentTarget.value)
      initialize_items(level)
          
    def open(idx, *list_of_args_for_animate):
      turn(True, idx, *list_of_args_for_animate)
    
    def close(idx, *list_of_args_for_animate):
      turn(False, idx, *list_of_args_for_animate)
    
    def turn(front, idx, *list_of_args_for_animate):
      if front:
        img_num = indexes[idx]
        img_path = f'/wp/wp-content/uploads/2019/03/icon-128px-{img_num}.jpeg'
      else:
        img_path = f'/wp/wp-content/uploads/2019/03/icon-128px-hatena.jpeg'
      q=jq(f'#item li:eq({idx}) img')
      q.attr('src', img_path)
      if list_of_args_for_animate:
        animate(q, *list_of_args_for_animate)
    
    def animate(target, *list_of_args_for_animate):
      if isinstance(target, int):
        idx = target
        q = jq(f'#item li:eq({idx}) img')
      else:
        q = target
      for args in list_of_args_for_animate:
        q.animate(*args)
    
    def close_selected_items():
      global selected,ignore_select
      close(selected[0])
      close(selected[1])
      selected = []
      ignore_select = False
    
    def splash(*, reset_after_splash=False):
      global ignore_select
      ignore_select = True
      jq('#gameLevel').prop('disabled',True)
      cur = 0
      ptn = list(range(N_ITEM))
      shuffle(ptn)
    
      def f():
        nonlocal cur
        if cur < N_ITEM:
          close(ptn[cur], ({'zoom':'100%','opacity':1}, 100, f))
          cur += 1
        else:
          if reset_after_splash: reset(N_KIND)
          global ignore_select
          ignore_select = False    
          jq('#gameLevel').prop('disabled',False)
    
      return f
    
    def increase_penalties(p):
      # ペナルティの処理 (スコアを減点させる)
      global score
      score -= p
      jq('#score').text(score)
    
    def update_info_if_needed():
      # ハイスコアの更新と表示
      if hiScore is None or hiScore < score:
        global hiScore
        hiScore = score
        jq('#hiScore').text(hiScore)
    
    def check():
      global selected, cleared
      pos1,pos2 = selected[0],selected[1]
      if indexes[pos1] == indexes[pos2]:
        complete = len(cleared) == N_ITEM-2
        if not complete:
          for idx in selected:
            animate(idx, ({'zoom':'105%'},50),({'zoom':'40%','opacity':0.3},500))
        cleared.extend(selected)
        if complete:
          update_info_if_needed()
          args = ({'zoom':'40%','opacity':0.3}, 1000, splash(reset_after_splash=True))
          for idx in selected: animate(idx, args)
        else:
          selected = []
      else:
        if not trials[pos1]==trials[pos2]==1:
          if trials[pos1]!=1 and trials[pos2]==1:
            # 二枚目を完全な当てずっぽうで引いた場合のペナルティは2点
            increase_penalties(2)
          else:
            # そうでなければペナルティは1点
            increase_penalties(1)
        ignore_select = True
        timer.set_timeout(close_selected_items, 350)
    
    initialize_items(DEFAULT_GAME_LEVEL)
    </script>

    コメントを残す

    メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

    このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください