PazQ(パズキュー)

https://github.com/mstka/No1_58_hack_PJ

GitHub

Figma

Python

JavaScript

VSCode

クイズ✖️パズルを掛け合わせた新感覚ゲーム!!

ネムレス(Nameless)

NEKΦωΦTI

mashamo

Luu Manh Hung(リュー)

推しアイデア

パズルを完成させるとクイズが浮かび上がってくる...!? クイズ力とパズル力の両方が試される新感覚ゲーム! AIが問題文を作るため問題数は無限大!! いつでも、どこでも、誰とでも!

作った背景

第一回58ハッカソン 全員がはじめましてのチームだったので、アイスブレイクになるような面白いゲームを作れないかと思って今回このゲームを作ることになりました

推し技術

・プロンプトにこだわったGeminiによる問題文や回答の生成 ・パズルの余白が均一になるように微調整 ・フレームワークを使わずにPythonでhttps通信 ・直感的に操作できるパズルのUI

プロジェクト詳細

概要

パズルを解くと問題が浮かび上がってくる! 問題は生成AIで好きな難易度、好きなカテゴリーで遊べる! 友達と対戦もできる!

簡単な技術の解説

大まかな流れ

  1. discord botを用いて問題設定をバックエンドに送信
  2. Geminiにプロンプトを送ってクイズを生成
  3. 生成した問題文を元にPIL、matplotlibを用いて画像生成、分割
  4. 生成した画像、クイズの情報をフロントエンドに送信
  5. JSでパズルゲームの実施。解答が早い順にユーザを保存しランキング表示

image discord bot:ゲームの部屋を作成。開始時刻、終了時刻、難易度、ジャンルを設定できる。 Python:バックエンド全般を担当。 matplotlib:ランダムな図形を生成し、背景画像を作成する。 PIL:文字列を背景画像に挿入し分割を行う。 Gemini:Googleの提供する生成AI。問題文、選択肢、解答の作成とチェックに使用。 Java Script:パズル、クイズを行うWebページの作成 VITE:環境構築 Vercel:デプロイ、実行

生成AI(Gemini)を用いた問題文・選択肢・解答の生成

大まかな流れ

  1. APIの取得・設定
  2. 問題文・選択肢・解答の生成
  3. 生成した問題文・解答をgeminiにもう一度投げて妥当性を数値化、80に満たない場合 2 にもどる

問題文・選択肢・解答の生成

  • 問題の難易度、カテゴリーの初期値を設定。プロンプト内に変数を使うことで難易度やカテゴリーをユーザーが自由に設定できる。(例えば「大学生」「7歳」など)
  • カテゴリーは選ばなければランダムに選択される。
  • フロントエンドに渡しやすいようjson形式で問題文、選択肢(list)、解答を保存
  • ハルシネーションや問題になっていないようなクイズをはじくため、accuracy_checkを行う。
def make_quiz(level='一般人',category='None'): categories = ['一般常識', '化学', '日本史', '世界史', '数学', '物理学', '生物学', '天文学', '地理学', '経済学', '政治学', '哲学', '心理学', '社会学', '文学', '詩', '演劇', '映画', '音楽', '美術', '建築', '写真', 'デザイン', 'テレビ番組', 'コンピューターサイエンス', 'テクノロジー', '機械学習', 'データサイエンス', 'ゲームデザイン', 'スポーツ', 'サッカー', '野球', 'バスケットボール', 'テニス', 'オリンピック', 'ファッション', '料理', '飲み物', 'ワイン', 'チーズ', 'フルーツ', '野菜', '魚介類', '肉料理', 'デザート', 'お茶', 'コーヒー', 'パスタ', 'ピザ', 'パン', '和食', '中華料理', 'イタリア料理', 'フランス料理', 'インド料理', 'メキシコ料理', '韓国料理', '天気', '環境問題', 'エネルギー', '宇宙探査', 'ロボット工学', '人工知能', '遺伝学', '神経科学', '医学', '健康とフィットネス', '栄養学', '言語学', '翻訳', '文法', '語彙', '民俗学', '宗教', '神話', '伝説', '漫画', 'アニメ', '映画', 'テレビシリーズ', 'ファンタジー', 'サイエンスフィクション', '推理小説', '恋愛小説', 'ドラマ', 'コメディ', 'ホラー', 'スリラー', '戦争', '動物', '鳥類', '爬虫類', '昆虫', '恐竜', '海洋生物', '植物', '天気', '地震', '火山'] if(category=='None'): category = random.choice(categories) while True: prompt = f""" 4択問題を一つだけ考えてください。知識問題でジャンルは{category}、難易度は{level}が解くレベルです。過去に出した問題と似た問題は出してはいけない。コードを書いてはいけない。答えは一つになるように。 以下の形式で答えてください: {{ "question": "string", "choices": ["string1", "string2", "string3", "string4"], "answer": "string" }} """ response = model.generate_content(prompt) #JSONとしてパースする try: response_data = json.loads(response.text) except json.JSONDecodeError as e: print(f"JSONDecodeError: {e.msg}") print(f"Error at line: {e.lineno}, column: {e.colno}, char: {e.pos}") print(f"Response Text:", repr(response.text)) continue # 再試行する question = response_data['question'] answer = answer=response_data['answer'] if (accuracy_check(question,answer)==True): break print('問題と答えの整合性が取れていない') print(question,answer) return response_data

自由記述の問題ではハルシネーションやそもそもクイズが成り立たないことが多かったが、4択にすることで精度が上がった。 生成AIの柔軟性を活かしてユーザに合った問題を作成できる。 プロンプトは出力結果を元に制約をつけたり、希望を増やすことで望むものに近づけていく。

正確性の確認

問題文と解答をgeminiに与えて正確性のスコアをつけさせる。生成された成り立っていない問題のパターンの場合、特にスコア(check_num)が低くなるようにプロンプトで指定している。 返り値はTrue or False。 数字でないものがスコアに入った場合プロンプトエラーと出力、Falseを返している extract_number_from_stringは文字列から数字だけ抜き出す関数。

def accuracy_check(question,answer): #妥当性の数値化 prompt = f"""{question}の答えとして{answer}は妥当の妥当性を1から100の整数で評価して。100が最も妥当性が高いとする。クイズとして成り立っていない場合、問題に回答が含まれている場合、選択肢が重複する場合スコアは低くなる。答えは数字のみ。""" accuracy = model.generate_content(prompt) #textから数値だけ抜き出す check_num = extract_number_from_string(accuracy.text) if (check_num>=80): return True elif(check_num>=0 ): return False else: print('プロンプトエラー') return False

どうしてもクイズとしておかしいものが出力されてしまうことはあるのでここでチェックを挟んでいる。 妥当性を数値化することで定量的に判断を下すことが可能になった。

matplotlibとpillow(PIL)を使用した画像処理

大まかな流れ

  1. 受け取った文字列を画像に合うように変換
  2. random関数を使った背景の作成
  3. 1と2を合体
  4. 作成した画像を16分割

1. 受け取った文字列を画像に合うように変換

# 複数行の文字を出すのに、行ごとリストに入れてforで回す texts = [] quiz = [] with open(str(input()),"r") as f: #入力されたファイルを読み込む(デフォでNO1_58_...スタートになってる) for w in f.read(): #ファイルの文章を読み込む texts.append(w) #それぞれの文字列を1つの文字列に変換する print("texts : ",texts) #[こ, れ, , は, 、, 文, , 字, , 列, , か, , ら, , 作, , ら, , れ, , た, , , 画, , 像, , で, , す, , , 。] ntexts = "".join(texts) print("ntexts : ",ntexts) #これ は、文 字 列 か ら作 ら れ た 画 像 で す 。 quiz.append(ntexts) print("quiz : ",quiz) #['これ は、文字列から作られた 画像です 。'] # test = ["こ れ は、","文 字 列 か ら","作 ら れ た ","画 像 で す 。"]

read受け取った文字列は1文字ずつ入れられるのでそれを1つの文としてリストに入れ直した。

#文字列を正方形に合うよう平方にして、それぞれのリストに入れる quarter = math.ceil(len(texts)**0.5) print("quarter : ",quarter) nquiz = [] for i in range(quarter): nquiz.append(ntexts[i*quarter:(i+1)*quarter]) print("nquiz : ",nquiz) # PCローカルのフォントへのパスと、フォントサイズを指定 font_path = "/Library/Fonts/BIZUDGothic-Bold.ttf" font = ImageFont.truetype(font_path, 60)

元は、縦は4行に固定して、横の長さを文字数に合わせて変更。それを基準に縦の長さを変更していたため、文字数が多いとどうしても縦が大きくなって、同じ4行だと文字数が多いほど空白が大きくなってしまっていた。

→そこで、文字の行列を等しくすることで、空白を出来るだけへらすことにした。よって、平方根で辺の長さを決定。

2. random関数を使った背景の作成

shapes = ["o","v","s","8","p","*","h","D","X"] for h in range(4): for i in range(4): for j in range(4): #位置の記録 # 正方形の中心位置を計算 x_center = (j + 0.5) * square_size y_center = (i + 0.5) * square_size # print(x_center,y_center) # ランダムなオフセットを追加 x_offset = random.uniform(-0.4 * square_size, 0.4 * square_size) y_offset = random.uniform(-0.4 * square_size, 0.4 * square_size) # print(x_offset,y_offset) x = x_center + x_offset y = y_center + y_offset # print(x,y) size = random.uniform(100, 3000) # マーカーサイズ(面積) color = (random.random(), random.random(), random.random()) # ランダムな色 marker = random.choice(shapes) ax.scatter(x, y, s=size, c=[color], marker=marker, alpha=0.4, )

random関数を使い、図形の座標・サイズ・色・形を全て乱数で作成。 中心点を変えながらズレをつかうことで、図形の偏りを減らしつつを普遍的に配置することに成功した。

3. 1と2を合体

# 背景画像を読み込む backgroundImage = Image.open("Produce_Image/backgroundImgProto.jpg") #文字サイズに合わせて画像をトリミング text_size = quarter*60 + 20 backgroundWidth , backgroundHeight = backgroundImage.size left = (backgroundWidth - text_size) //2 top = (backgroundHeight - text_size) //2 right = left + text_size bottom = top + text_size

受け取る問題文は毎回文字数が異なるので、文字数に合わせて画像サイズをトリミング。画像いっぱいに文字が並ぶようにした。

# 文字描画の初期位置(画像左上からx, yだけ離れた位置) x = 10 y = 10 if len(ntexts) <= (quarter**2)-quarter: y += 30 # 文字の描画 for i in range(quarter): # 描画位置、描画する文字、文字色、フォントを指定 draw.text((x, y), nquiz[i], fill=(0, 0, 0), font=font, stroke_width=2, stroke_fill=(250,250,250)) y += 60 # ファイルに出力 cropper_bg.save("Produce_Image/image.png")

文字数によっては下の空白が大きくなってしまうので、文字数に応じて描画開始地点を調整し描画後、保存。

例えば、平方数を基準にしているので25文字だと5×5でピッタリなのだが、26文字となるとはみでるため、6×6にしないといけない。そうすると、36文字の空きに対して26文字しか入れられないため、10文字も空白ができる。つまり、一番下の行が空白になり、分割した時に真っ白になる可能性がある。そこで、開始地点を条件分岐により変更することで空白ができるだけ偏らないようにした。

4. 作成した画像を16分割

def ImgSplit(im): height = (quarter*60 + 20) // 4 width = (quarter*60 + 20) // 4 buff = [] #縦の分割 for y1 in range(4): #横の分割 for x1 in range(4): #画像の切り取り x2 = x1 * width y2 = y1 * height # print(x2,y2,width + x2,height + y2) c = im.crop((x2, y2, width + x2, height + y2)) buff.append(c) return buff

画像サイズはバラバラだが、文字サイズは固定なので文字サイズに合わせて分割サイズを決定。

Pythonを使用したフレームワークを用いないバックエンド

大まかな流れ

<API提供サーバーとしてのレスポンス機能>

1. ソケットを用意し、通信をSSL化してクライアントからの接続を待つ

2. 接続があり次第指定された処理を実行

1.「 https://example.com:3000/q_and_a 」 メソッド: GETメソッド 概要  : 問題文と選択肢と回答と回答の期間をjson形式で送信
2.「 https://example.com:3000/img_list 」 メソッド: GETメソッド 概要  : 画像の並べ方をjson形式で送信
3.「 https://example.com:3000/send_answer 」 メソッド: POSTメソッド 概要  : 指定されたユーザーの回答を受理

<Discord_Bot>

1. DiscordのAPIに接続してBotを起動

2. コマンドが実行され次第、開始時刻・終了時刻・難易度・ジャンルを指定し、Geminiに問題文と選択肢と答えを生成

3. 生成された問題文からパズルの素材となる分割された画像を生成

4. Discordに作成したルームの概要をembed形式で送信する。

結合の苦悩

Geminiによる問題文生成処理と画像の処理部分をバックエンド側に結合した途端にエラーが発生し、そのデバッグで少し苦労しました。

CORSの苦悩

途中で何回もCORSにぶつかりましたが、ヘッダーの見直しをして解決することが出来ました。今回はフレームワークを使用せずにバックエンドを作成したので参考になるような文献が存在せず、デバッグがかなり大変でした。

Discord BOTの苦悩・工夫

開発の段階でDiscordのトークンをGitHubで間違えて共有してしまい、それに気づかずにいると、Discordの公式からDMで警告文が送られてきてAPIが停止した。もう一度違うBOTを作り直し、.gitignoreを使って共有しないということを覚えた貴重な体験でした。 また、ジャンルや難易度を設定しやすくするために、あらかじめスラッシュコマンドの選択肢に事前に用意したジャンルと難易度を格納できるようにした。 ルーム作成後にユーザーに難易度やジャンル、開始時刻と終了時刻を明確に伝えるために埋め込みメッセージを使用して見やすくした。

フレームワークを使わずに作成した苦悩

まずはhttp通信をhttps通信にするためにSSL化するという処理が参考になるような資料がほとんどなかったため実装に非常に苦労した。 実装に当たってhttpヘッダ等をしっかりと理解したうえで自分で一から書かないといけないので非常に勉強にはなる。 また、複数のクライアントから接続を受け入れられるようにthreadingライブラリーを使って通信を並列処理した。 開発を簡単かつ柔軟に行えるようにhttp通信を管理するクラスに関数を登録し、pathに応じた関数が呼び出されるようにしたことでpathに応じてすぐに処理を実行できるようにした。

あと、Let's Encryptから持ってきてたSSL証明書の有効期限が切れてたせいでチーム内デモの時にMacユーザーがゲームからはじかれるという事象が発生していたので証明書を更新した。証明書の取得に際して、DNSレコードに持ち主確認用のTXTを設置するなどコーディング以外の部分でもそれなりに苦労した。

正直フレームワークを使ったほうがエラーも減るとは思うが、自分で一から作ったという誇りと愛着は自作したときにしか得られないものであると感じた。 あと今回はそれぞれが自分の出来る部分でしっかりと創意工夫をしながら開発に取り組み、当初の目標通りの開発が出来たので非常に満足している。

フロントエンド

フロントエンドからバックエンドまでのつなぎ込みの時にデバッグがすごい大変だった。 ゲームのレイヤーが分からなくて苦労したけど何とか完成した。 バックエンドのAPIを使う時に、そもそもAPIがちゃんと動いているかのチェックをしながらフロントエンドを実装しないといけなかったのが大変だった。 スライドパズルの順番をばらばらにする処理を頑張って作った。 ゲームの機能を作るのも初めてだったので非常に苦労した。

バックエンドのAPIを理解するためにReadmeのドキュメントを頑張って読んだ。 APIドキュメントの大切さがわかった。

デモ

discordでの設定、部屋たて image image タイトル画面 名前の設定(設定しなくても自動で割り当てられる)、参加ができる。Playを押すと次のセッションに参加できる。 image ゲーム画面 左のパネルを文字や図形を頼りに右の枠に当てはめていく image image パズルが完成すると選択肢が表示される image 正解するとランキングに登録される image

ネムレス(Nameless)

@8ea5257e7473f21e