🎮

【個人開発】1-infinityというブラウザゲームを開発しました

2024/12/18に公開

はじめに

皆さんは1/8192という素晴らしいゲームを知っていますか?

https://store.steampowered.com/app/3014150/18192/?l=japanese

これは単純に1/2を当てていき、1/8192まで到達したらクリアという非常にシンプルなゲームです。

これがYoutubeの実況者などの間で結構流行っていて、とてもおもしろいコンセプトで素晴らしいと感じたので、自分も似たようなゲームをwebで誰でも遊べるものを作ってみようと思いました。

ちなみに私はこういうシンプルかつエレガントなアイデアを形にしているゲーム大好きです
8番出口とか、ジオゲッサー、スイカゲームとか

作ったアプリ

https://1-infinity.vercel.app/

コードはOSS(オープンソースソフトウェア)として公開して、誰でも気軽に機能改善、編集などを行えるようにしたいと考えたのですが、ゲームという性質からコードの中身が見えてしまうと仕組みなどがすべてわかってしまうので、ゲームを純粋に遊ぶ人のことやセキュリティ面などを考慮してプライベートリポジトリとして開発しています。

とはいえ、機能改善だったり一緒に開発したいという方がいれば大歓迎なので、その際はX(Twitter)などで気軽にご連絡ください。

機能としては非常にシンプルで赤か緑の宝箱の前でAボタンを押すことで、1/2の判定が走り、正解したらスコアが伸びていきます。

ログイン機能も実装しており、ユーザー登録やユーザーの編集機能もあります。

これはまだ未実装の機能ですが、ユーザーのスコアランキングもこれから実装していきます

使用技術

私はNext.jsが好きなので、Next.js 14 App Routerを使用することにしました。

以前個人開発でNext.js 13を使用してフリマサイトを作成していたのですが、その際認証機能の実装が非常に面倒でした。

Googleログイン機能の実装、ログインしているユーザーしか見られないページの実装、ユーザー編集ページはログインしているユーザーが編集しているユーザーと一致している場合のみ編集を許可するなど、「よーし、こんないいアイデア思いついたからアプリを個人開発してみよう!」となった場合にここらへんの仕組みから作っていくのはめんどくさいしテンションも上がりません。

そこで、t3-stackという非常に便利なテンプレートのようなものを見つけたので、これを使用することにしました。

What is t3-stack?

https://create.t3.gg/

T3 Stackとはsimplicity(簡潔さ)、modularity(モジュール性)、full-stack type safety(フルスタックの型安全)を追求した思想に焦点を当てています。

使い方としては以下のコマンドをCLI実行するだけで対話式でアプリケーションに必要なライブラリ等がインストールできる形になっています

npm create t3-app@latest

Need to install the following packages:
create-t3-app@7.35.0
Ok to proceed? (y)
   ___ ___ ___   __ _____ ___   _____ ____    __   ___ ___
  / __| _ \\ __| /  \\_   _| __| |_   _|__ /   /  \\ | _ \\ _ \\
 | (__|   / _| / /\\ \\| | | _|    | |  |_ \\  / /\\ \\|  _/  _/
  \\___|_|_\\___|_/‾‾\\_\\_| |___|   |_| |___/ /_/‾‾\\_\\_| |_|

│
◇  What will your project be called?
│  t3-app
│
◇  Will you be using TypeScript or JavaScript?
│  TypeScript
│
◇  Will you be using Tailwind CSS for styling?
│  Yes
│
◇  Would you like to use tRPC?
│  Yes
│
◇  What authentication provider would you like to use?
│  NextAuth.js
│
◇  What database ORM would you like to use?
│  Prisma
│
◇  Would you like to use Next.js App Router?
│  Yes
│
◇  What database provider would you like to use?
│  PostgreSQL
│
◇  Should we initialize a Git repository and stage the changes?
│  No
│
◇  Should we run 'npm install' for you?
│  Yes
│
◇  What import alias would you like to use?
│  ~/

Using: npm

✔ t3-app scaffolded successfully!

自分は以下のような技術を使用しました。

  • Next.js 14 App Router
  • tanstack query
  • trpc
  • prisma
  • zod
  • shadcn/ui
  • tailwind
  • cloudinary
  • react hook form
  • vitest
  • Supabase
  • pnpm
  • Bun
  • Google Analytics
  • Google Cloud
  • vercel

pnpmでパッケージ管理して、BunはGitHub ActionでのCI実行時のみに使用しています。

コマンドを実行しただけでもうすでに認証機能やtrpcを使用するための複雑な設定などは完了しています。非常に便利ですね。

t3-app/
├── prisma/  # Prisma関連
│   └── schema.prisma  # Prismaスキーマ定義ファイル
├── src/
│   ├── app/  # Next.jsのコードが格納されているディレクトリ
│   │   ├── _components/
│   │   │   └── create-post.tsx  # クライアントコンポーネント(use client)
│   │   ├── api/
│   │   │   ├── auth  # NextAuth.js関連
│   │   │   │   └── [...nextauth]
│   │   │   │       └── route.ts  # NextAuth.jsの設定とルーティングを処理
│   │   │   └── trpc
│   │   │       └── [trpc]
│   │   │           └── route.ts  # tRPCのエンドポイントのルーティングを処理
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── server/  # サーバー側のコードが格納されているディレクトリ
│   │   ├── api/
│   │   │   ├── root.ts  # tRPCルーターのルート設定、全てのエンドポイントをまとめる
│   │   │   ├── routers
│   │   │   │   └── post.ts # tRPCサブルーターの定義、投稿のCRUD操作に関するAPIの定義
│   │   │   └── trpc.ts  # tRPCの設定とコンテキストの作成、初期化処理
│   │   ├── auth.ts  # 認証関連のロジックを含むファイル
│   │   └── db.ts  # データベース接続の設定と初期化を行う、Prismaクライアントの作成
│   └── trpc/  # tRPC関連
│       ├── react.tsx  # クライアント側で使用するためのtRPCクライアント設定
│       └── server.ts  # サーバー側で使用するためのtRPCクライアント設定
├── .env
├── package.json
└── tailwind.config.ts  # Tailwind CSS設定

私が一番素晴らしいと感じた点は、apiを呼び出すためのコードを書く時にtrpcによって完全にドット開発手法が使えます。

例:
src/app/user/[id]/page.tsx

import { api } from "@/trpc/server";
import { notFound } from "next/navigation";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getServerAuthSession } from "@/server/auth";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import SafeSuspense from "@/app/_components/SafeSuspense";

interface UserPageProps {
  params: {
    id: string;
  };
}

export default async function UserPage({ params }: UserPageProps) {
  const user = await api.user.getUserById({ id: params.id });

  if (!user) {
    return notFound();
  }

  const session = await getServerAuthSession();

  const isOwner = session?.user.id === user.id;

  return (
    <SafeSuspense>
      {isOwner && (
        <div className="mb-4 text-center">
          <Button asChild variant="secondary" className="font-bold">
            <Link href={`/user/${user.id}/edit`}>編集</Link>
          </Button>
        </div>
      )}
      <Avatar className="mx-auto h-52 w-52 cursor-pointer">
        <AvatarImage
          src={user.image ?? "/assets/img/default.png"}
          alt="User Avatar"
        />
        <AvatarFallback className="text-8xl">
          {user.name?.charAt(0) ?? "U"}
        </AvatarFallback>
      </Avatar>
      <h2 className="text-center text-3xl font-bold">{user.name}</h2>
      <p className="whitespace-pre-line text-center text-lg text-gray-500">
        {user.introduction}
      </p>
    </SafeSuspense>
  );
}

これはユーザーページのコードですが、このコードにおいて

const user = await api.user.getUserById({ id: params.id });

ここは全て補完が効くのでapiのurlなどを意識することなく直感的に呼び出すことができます。
素晴らしいですね。

ちなみにクライアントコンポーネントでapiを叩く場合はtanstack queryを使用します
この場合も補完が効くのでapi側のコードを直感的に即座に書くことができます。

src/app/_components/post.tsx
``
  const createPost = api.post.create.useMutation({
    onSuccess: async () => {
      await utils.post.invalidate();
      setName("");
    },
  });

こんな感じでapi routeでapiを書くのが非常に簡単で、認証もpublicProcedureprotectedProcedureを先頭に付けるだけです

現状はapi routeでデータ操作を書いてしまっていますが、今後serverrr actionをより活用する方向にリファクタリングしていく予定です

セットアップ

私はGoogle ログイン機能を作りたかったので、プロバイダーだけ変更しました。
初期値ではDiscordになっています。

src/server/auth.ts

export const authOptions: NextAuthOptions = {
  callbacks: {
    session: ({ session, user }) => ({
      ...session,
      user: {
        ...session.user,
        id: user.id,
      },
    }),
  },
  adapter: PrismaAdapter(db) as Adapter,
  providers: [
    GoogleProvider({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
    /**
     * ...add more providers here.
     *
     * Most other providers require a bit more work than the Google provider. For example, the
     * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
     * model. Refer to the NextAuth.js docs for the provider you want to use. Example:
     *
     * @see <https://next-auth.js.org/providers/github>
     */
  ],
};

あとは以下を行うだけ

  • Supabaseのセットアップ
  • Google Cloudでプロジェクトを作成し、OAuth認証設定
  • cloudinaryでプロジェクトのセットアップ
  • NextAuthのシークレットを発行
  • Google Analyticsのプロジェクトのセットアップ
  • GitHubでリポジトリを作成
  • 環境変数を設定する
  • Vercelを使用してGitHubリポジトリと連携する

これだけでもうすでにプロジェクトが立ち上がり、ログイン機能までが実装されている状態になります。すごすぎる、、、、、

ローカルのDBを立ち上げるには./start-database.shを実行するだけでdockerが起動してローカルのpostglesが起動します。これも素晴らしいですね。

工夫した点

見た目に関して

見た目は割と良い感じになったかなと思ってます。
shadcn/uiはコンポーネントを直接リポジトリの中に含めるので、カスタマイズしやすいです。

最初は便利なhooksもあり、RSCにも対応しているNextUIを使用したいと思ったのですが、なぜかボタンの色など、その他のコンポーネントの背景色の設定がうまくいかなかったのでshadcn/uiとtailwindcssを使用してスタイリングしています。

v0との相性が非常に良いので、結果的には悪くはないんじゃないかなと思っています。

lint設定

自分は統一されたコードを書くことが好きなので、eslintの設定をある程度追加しています。
自分しかコードを書かないのでルールは弱めですが、もう少しいろいろ入れてもいいかなと思ってます

.eslintrc.cjs

/** @type {import("eslint").Linter.Config} */
const config = {
  parser: "@typescript-eslint/parser",
  parserOptions: {
    project: true,
  },
  plugins: ["@typescript-eslint", "jest-dom", "testing-library"],
  extends: [
    "next/core-web-vitals",
    "plugin:@typescript-eslint/recommended-type-checked",
    "plugin:@typescript-eslint/stylistic-type-checked",
  ],
  overrides: [
    {
      files: [
        "**/__tests__/**/*.+(ts|tsx|js)",
        "**/?(*.)+(spec|test).+(ts|tsx|js)",
      ],
      extends: ["plugin:jest-dom/recommended", "plugin:testing-library/react"],
    },
  ],
  ignorePatterns: ["vite.config.ts", "vitest.setup.ts"],
  rules: {
    "@typescript-eslint/array-type": "off",
    "@typescript-eslint/consistent-type-definitions": "off",
    "@typescript-eslint/consistent-type-imports": [
      "warn",
      {
        prefer: "type-imports",
        fixStyle: "inline-type-imports",
      },
    ],
    "@typescript-eslint/no-unused-vars": [
      "warn",
      {
        argsIgnorePattern: "^_",
      },
    ],
    "@typescript-eslint/require-await": "off",
    "@typescript-eslint/no-misused-promises": [
      "error",
      {
        checksVoidReturn: {
          attributes: false,
        },
      },
    ],
  },
};
module.exports = config;

CI/CD

私はapiのルートの1つのサービスごとにテストを書いていくことにしました。

テストを書いたら必ずCIで自動実行されるようにしたいので、github actionでlintとtestが走るようにしています。

個人開発とはいえ、リリースしているので自分が間違えたコードをmainブランチにpushしちゃったせいでアプリが動かなくなってしまうと嫌なので、必ずブランチを切って作業をしてCI/CDが通ってからmergeするようにしています。

本当はCode Rabbitも入れたいのですが、以前自分が使用した時には一ヶ月での使用料が5~6000円かかったのでちょっと検討中です(このときは一ヶ月で30~50PR
くらい出した。PRの要約機能だけでもかなり神機能)

https://www.coderabbit.ai/

.github/workflows/test.yaml

name: Bun🥟 CI

on: pull_request

jobs:
  setup:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout 🛎
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Cache 💾
        uses: actions/cache@v4
        id: cache
        with:
          path: |
            **/node_modules
          key: ${{ runner.os }}-build-${{ hashFiles('bun.lockb','package.json','prisma/schema.prisma') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ hashFiles('bun.lockb','package.json','prisma/schema.prisma') }}
        # cacheがヒットしなかった場合のみ、bun installとprisma generateを実行する
      - if: ${{ steps.cache.outputs.cache-hit != 'true' }}
        name: Setup Bun 🥟
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest
      - if: ${{ steps.cache.outputs.cache-hit != 'true' }}
        name: Install dependencies 📦
        run: bun install
      - if: ${{ steps.cache.outputs.cache-hit != 'true' }}
        name: Run prisma generate 🏭
        # erd generatorが動作しないよう該当行を削除してから実行
        run: sed -i '11,13d' prisma/schema.prisma && bun run prisma generate

  # lintとtestを並列で実行する
  lint:
    runs-on: ubuntu-latest
    needs: setup
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      DIRECT_URL: ${{ secrets.DIRECT_URL }}
      NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
      NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
      GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
      GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
    steps:
      - name: Checkout 🛎
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Cache 💾
        uses: actions/cache@v4
        id: cache
        with:
          path: |
            **/node_modules
          key: ${{ runner.os }}-build-${{ hashFiles('bun.lockb','package.json','prisma/schema.prisma') }}
          restore-keys: |
            ${{ runner.os }}-build--${{ hashFiles('bun.lockb','package.json','prisma/schema.prisma') }}

      - name: Setup Bun 🥟
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      - name: debug 環境変数の確認 🐛
        run: |
          echo "DATABASE_URL is ${DATABASE_URL:+セットされています}"
          echo "DIRECT_URL is ${DIRECT_URL:+セットされています}"
          echo "NEXTAUTH_SECRET is ${NEXTAUTH_SECRET:+セットされています}"
          echo "NEXTAUTH_URL is ${NEXTAUTH_URL:+セットされています}"
          echo "GOOGLE_CLIENT_ID is ${GOOGLE_CLIENT_ID:+セットされています}"
          echo "GOOGLE_CLIENT_SECRET is ${GOOGLE_CLIENT_SECRET:+セットされています}"

      - name: Run lint 🧹
        run: bun run lint

  test:
    runs-on: ubuntu-latest
    needs: setup
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      DIRECT_URL: ${{ secrets.DIRECT_URL }}
      NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
      NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
      GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
      GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
    steps:
      - name: Checkout 🛎
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Cache 💾
        uses: actions/cache@v4
        id: cache
        with:
          path: |
            **/node_modules
          key: ${{ runner.os }}-build-${{ hashFiles('bun.lockb','package.json','prisma/schema.prisma') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ hashFiles('bun.lockb','package.json','prisma/schema.prisma') }}

      - name: Setup Bun 🥟
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

      - name: debug 環境変数の確認 🐛
        run: |
          echo "DATABASE_URL is ${DATABASE_URL:+セットされています}"
          echo "DIRECT_URL is ${DIRECT_URL:+セットされています}"
          echo "NEXTAUTH_SECRET is ${NEXTAUTH_SECRET:+セットされています}"
          echo "NEXTAUTH_URL is ${NEXTAUTH_URL:+セットされています}"
          echo "GOOGLE_CLIENT_ID is ${GOOGLE_CLIENT_ID:+セットされています}"
          echo "GOOGLE_CLIENT_SECRET is ${GOOGLE_CLIENT_SECRET:+セットされています}"

      - name: Run test 🧪
        run: bun run test

src/server/api/routers/user.ts

import { z } from "zod";
import {
  createTRPCRouter,
  publicProcedure,
  protectedProcedure,
} from "@/server/api/trpc";
import { getUserById } from "./user/getUserById";
import { getUserList } from "./user/getUserList";
import { updateUser, updateUserInputSchema } from "./user/updateUser";

export const userRouter = createTRPCRouter({
  getUserById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(getUserById),

  getUserList: publicProcedure.query(getUserList),

  updateUser: protectedProcedure
    .input(updateUserInputSchema)
    .mutation(updateUser),
});

src/server/api/routers/user/getUserById.ts

import { TRPCError } from "@trpc/server";
import { z } from "zod";
import type { Context } from "../types/context";

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const getUserByIdInput = z.object({ id: z.string() });
type GetUserByIdInput = z.infer<typeof getUserByIdInput>;

/**
 * 指定されたIDに基づいてユーザー情報を取得
 *
 * @param {Object} params
 * @param {Context} params.ctx - コンテキストオブジェクト
 * @param {GetUserByIdInput} params.input - ユーザーIDを含む入力オブジェクト
 * @returns {Promise<Object>}
 * @throws {TRPCError} ユーザーが見つからない場合にエラーをスロー
 */
export const getUserById = async ({
  ctx,
  input,
}: {
  ctx: Context;
  input: GetUserByIdInput;
}) => {
  const user = await ctx.db.user.findUnique({
    where: { id: input.id },
    select: {
      id: true,
      name: true,
      image: true,
      introduction: true,
    },
  });

  if (!user) {
    throw new TRPCError({
      code: "NOT_FOUND",
      message: "User not found",
    });
  }
  return user;
};

今後追加する機能

  • 各種操作にloading表示を追加
  • サービスの紹介ページの追加
  • ユーザーのスコアランキング表示機能を追加
  • 選択した宝箱のログを確認できるようにする
  • 正解か不正解のフィードバックをポップアップにする
  • マップのパターンやオブジェクトを追加する
  • 結果共有機能を追加する

最後に

trpcやNextAuthなど、設定が少し面倒に感じる機能をほぼ自分で触ることなくいきなり作りたい機能の実装から開始できるt3-stack templeteは本当に素晴らしいと感じました。

最近はDrizzleなども勢いがすごく、便利な連携のためのライブラリが作られていたりとかなり使いやすそうなので学んでいきたいです。

最後にもう一度宣伝になりますが、せっかく作ったので触って遊んでみたり、引用やいいねなどしていただけるとすごく嬉しいです!

https://1-infinity.vercel.app/

参考

https://zenn.dev/kiwichan101kg/articles/279cc65988a39b

注意事項

  • googleログインなどはセキュリティ上不安がある方は利用しないことをおすすめします

Discussion