diff --git a/README.md b/README.md index 711fab6..2407572 100644 --- a/README.md +++ b/README.md @@ -1 +1,116 @@ -# WebSocket-Learn \ No newline at end of file +# WebSocket 学習レポ + +**題材:** 10人同期のブラウザ蛇ゲーム(Slither.io風) +**対象:** WebSocketほぼ初心者 / 素のJavaScriptで進める +**ゴール:** WebSocketの基本から、リアルタイム同期・状態管理・遅延対策・簡易スケーリングまで理解する + +--- + +## このリポジトリについて + +「動いた」で終わらせず、**「なぜそう設計するのか」を説明できる**状態を目指す学習教材です。 + +初心者が詰まる原因は大体この3つです。 + +1. HTTPとWebSocketの違いが曖昧 +2. 入力と状態を混同する +3. クライアント側に真実を持たせて壊れる + +このカリキュラムでは、そこを順番に潰していきます。 + +--- + +## 技術スタック + +| 分類 | 技術 | +|---|---| +| フロント | HTML / CSS / 素のJavaScript / Canvas API | +| バックエンド | Go + echo + gorilla/websocket | +| 実装では使わないもの | React, TypeScript, Redux, DB | +| 概念として触れるもの | Redis(Step 12 で Pub/Sub の役割のみ解説、実装なし) | + +> **注:** Redis は Step 12 で「複数サーバー構成ではどう使うか」を概念レベルで説明します。 +> Step 0〜11 の実装コードには登場しません。 + +理由は単純で、**理解前に道具を増やすと本質が見えなくなるから**です。 + +--- + +## ステップ一覧 + +| Step | テーマ | フォルダ | +|---|---|---| +| Step 0 | HTTPとWebSocketの違いを理解する | [step-00-http-vs-websocket](./step-00-http-vs-websocket/) | +| Step 1 | まずは1本つなぐ(エコーサーバー) | [step-01-echo](./step-01-echo/) | +| Step 2 | 複数人に配る(ブロードキャスト) | [step-02-broadcast](./step-02-broadcast/) | +| Step 3 | メッセージではなく「入力」を送る | [step-03-input](./step-03-input/) | +| Step 4 | サーバーで状態を持つ | [step-04-server-state](./step-04-server-state/) | +| Step 5 | ゲームループを入れる | [step-05-game-loop](./step-05-game-loop/) | +| Step 6 | 蛇ゲームにする | [step-06-snake](./step-06-snake/) | +| Step 7 | 遅延と同期ズレを体験する | [step-07-lag](./step-07-lag/) | +| Step 8 | 補間と予測を入れる | [step-08-interpolation](./step-08-interpolation/) | +| Step 9 | 10人部屋を作る | [step-09-rooms](./step-09-rooms/) | +| Step 10 | 切断・再接続に対応する | [step-10-reconnect](./step-10-reconnect/) | +| Step 11 | 最適化を考える | [step-11-optimization](./step-11-optimization/) | +| Step 12 | 上級発展:1台を超える設計へ | [step-12-advanced](./step-12-advanced/) | + +--- + +## 進め方のルール + +各ステップでやることは4つです。 + +1. **概念理解** — READMEを読む +2. **最小実装** — コードを自分で打ち込む(コピペではなく) +3. **動作確認** — 実際に動かす +4. **言語化** — 以下を自分の言葉で書く + +毎ステップ必須のアウトプット: +- 「何を学んだか」 +- 「なぜその設計にしたか」 +- 「どこがまだ分からないか」 +- 「次のステップで何が増えるか」 + +--- + +## おすすめの進め方 + +``` +Step 0〜2 → 最速で終わらせる(接続の感触をつかむ) +Step 3〜5 → 本質理解(ここが最重要) +Step 6 → ゲームらしさを入れる +Step 7〜8 → 「リアルタイムの現実」を学ぶ +Step 9〜12 → 設計者の視点に上げる +``` + +**一番大事なのは Step 4 と Step 5 です。** +ここが曖昧なまま進むと、用語を眺めるだけになります。 + +--- + +## 各ステップのフォルダ構成(Step 1〜11) + +``` +step-XX-name/ +├── README.md # 概念説明・コード解説・練習問題 +├── server/ +│ └── main.go # Goサーバー +└── client/ + └── index.html # フロントエンド +``` + +--- + +## 最終到達イメージ + +このカリキュラムを終えたら、最低でも以下が言える状態を目指します。 + +- [ ] WebSocketの接続と基本イベントを説明できる +- [ ] ブロードキャストの仕組みを実装できる +- [ ] 入力送信と状態同期の違いを説明できる +- [ ] サーバー権威型の設計を理解している +- [ ] tickベースのゲームループを実装できる +- [ ] 遅延やカクつきの原因を説明できる +- [ ] 補間の役割を理解している +- [ ] 10人部屋の基本設計を考えられる +- [ ] 切断・再接続・ルーム分割など、現実の問題を意識できる diff --git a/step-00-http-vs-websocket/README.md b/step-00-http-vs-websocket/README.md new file mode 100644 index 0000000..d835c8f --- /dev/null +++ b/step-00-http-vs-websocket/README.md @@ -0,0 +1,193 @@ +# Step 0: HTTPとWebSocketの違いを理解する + +## このステップの目標 + +WebSocketが何のために存在するのかを理解する。 +コードはまだ書かない。まず「なぜ必要か」を頭に入れる。 + +--- + +## 1. HTTPとは何か + +HTTPは **リクエスト/レスポンス** モデルです。 + +``` +クライアント → 「このページをください」 → サーバー +クライアント ← 「はい、どうぞ」 ← サーバー +(接続が閉じる) +``` + +特徴: +- クライアントが「聞く」、サーバーが「答える」の一方通行 +- 1回のやり取りで接続が終わる(または Keep-Alive でも原則クライアント発) +- サーバー側から突然クライアントに話しかけることが**できない** + +--- + +## 2. WebSocketとは何か + +WebSocketは **全二重通信(Full-Duplex)** です。 + +``` +クライアント ←→ サーバー (接続を維持したまま、どちらからでも送れる) +``` + +特徴: +- 接続が確立したら、どちらからでも好きなタイミングで送れる +- 接続は明示的に閉じるまで維持される +- オーバーヘッドが少ない(HTTPヘッダーを毎回送らない) + +--- + +## 3. 比較表 + +| 項目 | HTTP | WebSocket | +|---|---|---| +| 通信の開始 | クライアントのみ | どちらからでも | +| 接続の持続 | 原則1回で終了 | 明示的に閉じるまで維持 | +| サーバーからのプッシュ | できない(工夫が必要) | できる | +| オーバーヘッド | 毎回ヘッダーが発生 | 初回接続時のみ | +| 向いている用途 | Webページ取得、REST API | チャット、ゲーム、株価など | +| 向いていない用途 | リアルタイム更新 | 単純なデータ取得 | + +--- + +## 4. なぜチャットや対戦ゲームでWebSocketが使われるのか + +### HTTPだけでチャットを作ろうとすると… + +方法1: **ポーリング(Polling)** +``` +クライアントが1秒ごとに「新しいメッセージありますか?」と聞き続ける +→ サーバーへの無駄なリクエストが大量発生 +→ 「ありません」という返答ばかり +→ リアルタイム性が低い(最大1秒の遅延) +``` + +方法2: **ロングポーリング(Long Polling)** +``` +クライアントが「新しいメッセージが来るまで待ってください」とリクエスト +→ サーバーはメッセージが来るまでレスポンスを保留 +→ メッセージが来たらレスポンスを返し、すぐ次のリクエストを送る +→ マシだが、コネクション管理が複雑 +``` + +### WebSocketなら… +``` +一度接続したら、サーバーから即座にメッセージをプッシュできる +→ 遅延がほぼない +→ 無駄なリクエストなし +→ 実装がシンプル +``` + +--- + +## 5. リアルタイムゲームでWebSocketが有効な理由 + +蛇ゲームを例に考えます。 + +``` +10人が同時にプレイしている場合: + +毎100msごとに全員の位置を更新したい +→ 1秒に10回、10人分の位置データを全員に送る必要がある + +HTTPポーリングなら: + - 10人 × 10回 = 毎秒100回のリクエスト + - それぞれにHTTPヘッダーが付く + - 遅延も不規則 + +WebSocketなら: + - 接続は10本だけ + - サーバーから一方的に位置データをプッシュ + - 無駄がない +``` + +--- + +## 6. WebSocketを使っても遅延はゼロにならない + +これは大事なポイントです。 + +``` +WebSocketを使っても遅延が発生する理由: + +1. 物理的な距離(東京→大阪でも数ms) +2. ルーターや回線の混雑 +3. OSのパケット処理時間 +4. アプリケーションの処理時間 +5. クライアント端末の性能 + +TCPベースのWebSocketは: + - パケットの順序を保証してくれる(順番通りに届く) + - 届いたことを確認してくれる(再送制御あり) + - しかし、「速く届く」は保証しない +``` + +つまり、**WebSocketはリアルタイム通信を「可能にする」ものであって、「遅延ゼロを保証する」ものではない**。 + +後のステップで、遅延があっても「滑らかに見せる」技術(補間・予測)を学びます。 + +--- + +## 7. WebSocketを使わなくていいケース + +WebSocketは便利ですが、万能ではありません。 + +| ケース | 適切な技術 | +|---|---| +| ブログの記事を読む | HTTP(普通のREST API) | +| フォームを送信する | HTTP | +| 1時間に1回データを取得 | HTTP(ポーリング) | +| 株価のリアルタイム表示 | WebSocket または SSE | +| チャット | WebSocket | +| オンラインゲーム | WebSocket(または WebRTC/UDP系) | + +--- + +## 8. WebSocketの接続確立の仕組み(補足) + +WebSocketは実はHTTPから始まります。 + +``` +1. クライアントがHTTPで「WebSocketに切り替えたい」とリクエスト + → これを「ハンドシェイク(handshake)」と言う + +2. サーバーが「OK、切り替えます」と返す(ステータス 101) + +3. 以降はWebSocketプロトコルで通信 +``` + +これをアップグレード(Upgrade)と言います。 +だからWebSocketのURLは `ws://` または `wss://`(SSL対応)を使います。 + +--- + +## 理解チェック + +以下の質問に自分の言葉で答えてみてください。 + +1. HTTPとWebSocketの一番大きな違いは何ですか? +2. なぜチャットアプリにHTTPポーリングは向いていないのですか? +3. WebSocketを使えば遅延はゼロになりますか?なぜそう思いますか? +4. `ws://` と `wss://` の違いは何ですか? +5. 蛇ゲームでHTTPだけを使うと何が困りますか?具体的に説明してください。 + +--- + +## 手を動かす課題 + +1. HTTPとWebSocketの違いを自分なりの表にまとめる +2. 「蛇ゲームでHTTPだけを使うと何が困るか」を200字以上で書く +3. ブラウザの開発者ツール(DevTools)でWebSocket通信を確認する + - Chrome/Firefox → F12 → Network タブ → WS フィルター + +--- + +## 次のステップへ + +Step 1では実際にWebSocket接続を試します。 +ブラウザとサーバーを繋いで、文字を送受信してみます。 + +「概念は分かった、でも実際どう動くの?」という疑問が出てきたら正解です。 +それを確かめるのが次のステップです。 diff --git a/step-01-echo/README.md b/step-01-echo/README.md new file mode 100644 index 0000000..dad45f0 --- /dev/null +++ b/step-01-echo/README.md @@ -0,0 +1,172 @@ +# Step 1: まずは1本つなぐ(エコーサーバー) + +## このステップの目標 + +- ブラウザからサーバーにWebSocket接続できる +- メッセージを送信し、サーバーがそのまま返せる(エコー) +- open / message / close / error イベントを理解する + +**ゲームはまだ作らない。まず「接続」を体で理解する。** + +--- + +## 重要な概念 + +### WebSocket の4つのイベント + +| イベント | タイミング | やること | +|---|---|---| +| `open` | 接続が確立したとき | 「接続完了」と表示 | +| `message` | データを受信したとき | 受け取ったデータを処理 | +| `close` | 接続が閉じたとき | 「切断」と表示 | +| `error` | エラーが発生したとき | エラー内容を表示 | + +### このステップで理解すべき本質 + +HTTPは「リクエストするたびに新しい接続」ですが、 +WebSocketは「**一度接続したらずっと維持される**」接続です。 + +ボタンを押すたびに接続するのではなく、 +**最初に一回接続して、その接続を使い回す**のがポイントです。 + +--- + +## ファイル構成 + +``` +step-01-echo/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## サーバーコード + +`server/go.mod`: +``` +module step01-echo + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) +``` + +`server/main.go` の内容は [server/main.go](./server/main.go) を参照。 + +--- + +## コード解説 + +### サーバー側(Go) + +``` +接続の流れ: +1. クライアントが ws://localhost:8080/ws に接続を要求 +2. echo がリクエストを受け取る +3. upgrader.Upgrade() で HTTP → WebSocket に切り替え +4. 無限ループでメッセージを待ち受け +5. 受け取ったメッセージをそのまま返す +``` + +重要なポイント: +- `upgrader.Upgrade()` が HTTP → WebSocket の「アップグレード」 +- `conn.ReadMessage()` はメッセージが来るまでブロックする(待ち続ける) +- `conn.WriteMessage()` でクライアントへ送信 +- `defer conn.Close()` で関数終了時に必ず接続を閉じる + +### クライアント側(JavaScript) + +```javascript +// WebSocket接続の作成 +const ws = new WebSocket('ws://localhost:8080/ws'); + +// 接続確立時 +ws.onopen = () => { ... }; + +// メッセージ受信時 +ws.onmessage = (event) => { ... }; + +// 接続切断時 +ws.onclose = () => { ... }; + +// エラー時 +ws.onerror = (error) => { ... }; + +// 送信 +ws.send('テキスト'); +``` + +--- + +## ローカル起動手順 + +```bash +# 1. サーバーの依存パッケージを取得 +cd step-01-echo/server +go mod tidy + +# 2. サーバーを起動 +go run main.go + +# 3. ブラウザで開く +# client/index.html をブラウザで直接開く(ダブルクリックでOK) +# または http://localhost:8080/ にアクセス(静的ファイル配信している場合) +``` + +> **Note:** Goが入っていない場合は `https://go.dev/dl/` からインストール + +--- + +## よくあるエラーと対処 + +### 1. `dial tcp [::1]:8080: connect: connection refused` +**原因:** サーバーが起動していない +**対処:** `go run main.go` でサーバーを起動してから接続する + +### 2. `upgrade: websocket: request origin not allowed` +**原因:** CORSポリシーでブロックされている +**対処:** サーバーの `upgrader` に `CheckOrigin: func(r *http.Request) bool { return true }` を追加する(開発用) + +### 3. `WebSocket is closed before the connection is established` +**原因:** 接続完了前に `ws.send()` を呼んでいる +**対処:** `ws.onopen` の中で送信する + +### 4. ブラウザコンソールに何も表示されない +**対処:** F12 で開発者ツールを開き、Console タブを確認する + +--- + +## 練習問題 + +1. **基本:** エコーサーバーを動かして、いくつかメッセージを送ってみる +2. **応用:** サーバー側でメッセージを大文字にして返すように変更する +3. **応用:** 送信回数をカウントして、メッセージと一緒に返すように変更する + - 例: `[1] hello` → `[1] hello` のように番号付きで返す + +--- + +## 理解確認クイズ + +1. `ws.onopen` はいつ呼ばれますか? +2. `ws.onmessage` の `event.data` には何が入っていますか? +3. サーバーが `conn.ReadMessage()` を呼んでいる間、何が起きていますか? +4. `defer conn.Close()` を書く理由は何ですか? +5. HTTPとWebSocketで、接続確立の仕方はどう違いますか? + +--- + +## 次のステップへ + +Step 1では「1対1」の通信を学びました。 +Step 2では「1対多」、つまり複数のクライアントに同じメッセージを送る方法を学びます。 + +「複数のタブを開いたとき、サーバーはどう管理するのか?」 +これが次の疑問です。 diff --git a/step-01-echo/client/index.html b/step-01-echo/client/index.html new file mode 100644 index 0000000..a1accf4 --- /dev/null +++ b/step-01-echo/client/index.html @@ -0,0 +1,192 @@ + + + + + + Step 1: WebSocket エコー + + + +

Step 1: WebSocket エコー

+ + +
切断中
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + diff --git a/step-01-echo/server/go.mod b/step-01-echo/server/go.mod new file mode 100644 index 0000000..b7cc4a8 --- /dev/null +++ b/step-01-echo/server/go.mod @@ -0,0 +1,20 @@ +module step01-echo + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-01-echo/server/main.go b/step-01-echo/server/main.go new file mode 100644 index 0000000..7c95988 --- /dev/null +++ b/step-01-echo/server/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// upgrader は HTTP 接続を WebSocket 接続にアップグレードするための設定 +// CheckOrigin: 開発用に全てのオリジンを許可(本番では制限すること) +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +func main() { + // Echo インスタンスを作成 + e := echo.New() + + // ログとリカバリのミドルウェアを追加 + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // 静的ファイル(client/index.html)を配信 + // ブラウザで http://localhost:8080/ にアクセスすると index.html が表示される + e.Static("/", "../client") + + // WebSocket のエンドポイント + // ブラウザから ws://localhost:8080/ws に接続する + e.GET("/ws", handleWebSocket) + + // サーバーをポート 8080 で起動 + fmt.Println("サーバー起動: http://localhost:8080") + log.Fatal(e.Start(":8080")) +} + +// handleWebSocket は WebSocket 接続を処理するハンドラ +func handleWebSocket(c echo.Context) error { + // HTTP → WebSocket にアップグレード + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + log.Printf("アップグレード失敗: %v", err) + return err + } + // 関数が終わったら必ず接続を閉じる + defer conn.Close() + + log.Println("新しいクライアントが接続しました") + + // メッセージを受け取り続けるループ + for { + // メッセージを受信(メッセージが来るまでここで待機する) + messageType, message, err := conn.ReadMessage() + if err != nil { + // 接続が切れたらループを抜ける + log.Printf("受信エラー(接続切断): %v", err) + break + } + + // 受け取ったメッセージをログに表示 + log.Printf("受信: %s", string(message)) + + // 受け取ったメッセージをそのままクライアントに返す(エコー) + err = conn.WriteMessage(messageType, message) + if err != nil { + log.Printf("送信エラー: %v", err) + break + } + } + + log.Println("クライアントが切断しました") + return nil +} diff --git a/step-02-broadcast/README.md b/step-02-broadcast/README.md new file mode 100644 index 0000000..5bc867f --- /dev/null +++ b/step-02-broadcast/README.md @@ -0,0 +1,157 @@ +# Step 2: 複数人に配る(ブロードキャスト) + +## このステップの目標 + +- 複数クライアントの接続を管理できる +- 誰かが送ったメッセージを全員に届けられる(ブロードキャスト) +- 切断したクライアントを適切に削除できる + +--- + +## 重要な概念 + +### ブロードキャストとは + +``` +クライアントA → サーバー → クライアントA + → クライアントB + → クライアントC +``` + +1人が送ったメッセージを、接続している全員に転送すること。 +チャットや同期ゲームの基本構造です。 + +### なぜ接続一覧が必要か + +Step 1では1対1でした。クライアントが1つなので、その接続にだけ返せばよかった。 +でも複数になると、「誰に送るか」を管理する必要があります。 + +```go +// 接続している全クライアントを保持するmap +clients = map[*websocket.Conn]bool{} + +// 新しい接続が来たら追加 +clients[conn] = true + +// 切断したら削除 +delete(clients, conn) + +// ブロードキャスト +for conn := range clients { + conn.WriteMessage(...) +} +``` + +### スレッドセーフの話(軽く触れる) + +Go では、各接続を別々の goroutine(並行処理単位)で扱います。 +複数の goroutine が同時に `clients` を読み書きすると、データ競合が起きる可能性があります。 + +この教材では `sync.Mutex`(排他ロック)を使って安全にします。 + +```go +var mu sync.Mutex + +// 書き込み前にロック +mu.Lock() +clients[conn] = true +mu.Unlock() +``` + +難しく考えなくていいです。「同時に触るときはロックする」という習慣だと思ってください。 + +--- + +## ファイル構成 + +``` +step-02-broadcast/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## コード解説 + +### サーバー側のブロードキャスト処理 + +``` +新しい接続 → clients に追加 → goroutine でメッセージ待機 + ↓ + メッセージ受信 + ↓ + 全クライアントに送信 + ↓ + 切断 → clients から削除 +``` + +### クライアント側 + +Step 1 のエコーサーバーから変わる点: +- 自分の送ったメッセージも含め、サーバーから受け取ったものを全て表示する +- 接続人数を表示する(サーバーから通知を受け取る) + +--- + +## ローカル起動手順 + +```bash +cd step-02-broadcast/server +go mod tidy +go run main.go +``` + +ブラウザで複数タブを開いて試す: +1. タブ1で `http://localhost:8080/` を開く +2. タブ2でも同じURLを開く +3. どちらのタブからメッセージを送っても、両方のタブに表示されることを確認する + +--- + +## よくあるエラーと対処 + +### 1. 送ったのに自分のタブに表示されない +**原因:** クライアント側でのみ表示するロジックを書いていない +**対処:** 全員に送るので、サーバーから受け取った `onmessage` を表示すれば自分にも届く + +### 2. タブを閉じてもカウントが減らない +**原因:** 切断処理 (`defer delete(clients, conn)`) が動いていない +**対処:** `ReadMessage` がエラーを返したときにループを抜けているか確認する + +### 3. goroutine leak(接続が増え続ける) +**原因:** `defer conn.Close()` を忘れている +**対処:** 接続ハンドラの最初に `defer conn.Close()` を書く + +--- + +## 練習問題 + +1. **基本:** 複数タブを開いて、ブロードキャストを確認する +2. **応用:** 接続時・切断時にシステムメッセージを全員に送る + - 例: 「ユーザーが入室しました」「ユーザーが退室しました」 +3. **応用:** 送信者自身には送らず、他の人だけに送る処理に変える + +--- + +## 理解確認クイズ + +1. なぜ `map[*websocket.Conn]bool` を使うのですか?(なぜ slice じゃないのか) +2. `sync.Mutex` が必要な理由を説明してください +3. ブロードキャスト中に切断が発生したらどうなりますか? +4. 接続数を表示するためにクライアントに何を送ればいいですか? +5. Step 2 のサーバーは「状態」を持っていますか?何が状態ですか? + +--- + +## 次のステップへ + +Step 2では「メッセージ」を同期しました。 +でもゲームで本当に必要なのは「操作情報」の送信です。 + +「矢印キーを押した」という情報をサーバーに送るにはどうすればいいか? +それが Step 3 のテーマです。 diff --git a/step-02-broadcast/client/index.html b/step-02-broadcast/client/index.html new file mode 100644 index 0000000..1b8e805 --- /dev/null +++ b/step-02-broadcast/client/index.html @@ -0,0 +1,125 @@ + + + + + Step 2: ブロードキャスト + + + +

Step 2: ブロードキャスト

+ +
切断中
+
接続人数: 0人
+ +
+ + +
+ +
+ + +
+ +
+ + + + diff --git a/step-02-broadcast/server/go.mod b/step-02-broadcast/server/go.mod new file mode 100644 index 0000000..ecd23af --- /dev/null +++ b/step-02-broadcast/server/go.mod @@ -0,0 +1,20 @@ +module step02-broadcast + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-02-broadcast/server/main.go b/step-02-broadcast/server/main.go new file mode 100644 index 0000000..3bdf574 --- /dev/null +++ b/step-02-broadcast/server/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// WebSocket アップグレーダー +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// clients は接続中の全クライアントを保持するmap +// *websocket.Conn をキー、bool を値として使う(セットとして利用) +var clients = make(map[*websocket.Conn]bool) + +// mu はクライアントマップへの同時アクセスを防ぐためのミューテックス +// 複数の goroutine が同時に clients を読み書きするので、排他制御が必要 +var mu sync.Mutex + +// Message はクライアントとサーバー間でやり取りするJSON形式 +type Message struct { + Type string `json:"type"` // メッセージの種類 ("chat", "system") + Content string `json:"content"` // メッセージ内容 + Count int `json:"count"` // 現在の接続数 +} + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // 静的ファイルを配信 + e.Static("/", "../client") + + // WebSocket エンドポイント + e.GET("/ws", handleWebSocket) + + fmt.Println("サーバー起動: http://localhost:8080") + log.Fatal(e.Start(":8080")) +} + +// broadcast は接続中の全クライアントにメッセージを送信する +func broadcast(msg Message) { + // JSON にシリアライズ + data, err := json.Marshal(msg) + if err != nil { + log.Printf("JSON変換エラー: %v", err) + return + } + + // mu.Lock() で clients マップへの排他アクセスを確保 + mu.Lock() + defer mu.Unlock() + + // 全クライアントにメッセージを送信 + for conn := range clients { + err := conn.WriteMessage(websocket.TextMessage, data) + if err != nil { + // 送信失敗した接続は削除(切断済みの可能性が高い) + log.Printf("送信エラー、接続を削除: %v", err) + conn.Close() + delete(clients, conn) + } + } +} + +// getClientCount は現在の接続数を返す +func getClientCount() int { + mu.Lock() + defer mu.Unlock() + return len(clients) +} + +// handleWebSocket は各 WebSocket 接続を処理する +func handleWebSocket(c echo.Context) error { + // HTTP → WebSocket にアップグレード + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + log.Printf("アップグレード失敗: %v", err) + return err + } + defer conn.Close() + + // 新しいクライアントを登録 + mu.Lock() + clients[conn] = true + count := len(clients) + mu.Unlock() + + log.Printf("新しいクライアントが接続 (現在: %d人)", count) + + // 入室を全員に通知 + broadcast(Message{ + Type: "system", + Content: "新しいユーザーが入室しました", + Count: count, + }) + + // クライアントが切断したときの後処理 + defer func() { + mu.Lock() + delete(clients, conn) + count := len(clients) + mu.Unlock() + + log.Printf("クライアントが切断 (残り: %d人)", count) + + // 退室を全員に通知 + broadcast(Message{ + Type: "system", + Content: "ユーザーが退室しました", + Count: count, + }) + }() + + // メッセージを受け取り続けるループ + for { + _, rawMsg, err := conn.ReadMessage() + if err != nil { + // 接続が切れたらループを抜ける + log.Printf("受信エラー(切断): %v", err) + break + } + + // 受け取ったメッセージを全員にブロードキャスト + log.Printf("受信してブロードキャスト: %s", string(rawMsg)) + broadcast(Message{ + Type: "chat", + Content: string(rawMsg), + Count: getClientCount(), + }) + } + + return nil +} diff --git a/step-03-input/README.md b/step-03-input/README.md new file mode 100644 index 0000000..3e33fed --- /dev/null +++ b/step-03-input/README.md @@ -0,0 +1,177 @@ +# Step 3: メッセージではなく「入力」を送る + +## このステップの目標 + +- キーボード入力(矢印キー)をサーバーに送れる +- サーバーでプレイヤーごとの最新入力を管理できる +- 「状態を送る」と「入力を送る」の違いを理解する + +--- + +## 最重要:なぜ「入力」を送るのか + +初心者が最初にやりがちなミスがこれです。 + +### ❌ ダメな設計:状態(座標)をクライアントが送る + +```json +{ "x": 150, "y": 200, "direction": "up" } +``` + +問題: +- **チートし放題**: クライアントが `{ "x": 9999, "y": 9999 }` と送ればどこへでも移動できる +- **不整合が起きやすい**: 複数クライアントがそれぞれの座標を送ると、誰の状態が正しいか分からなくなる +- **サーバーが無力化される**: サーバーがゲームを制御できない + +### ✅ 正しい設計:入力(意図)をクライアントが送る + +```json +{ "direction": "up" } +``` + +利点: +- クライアントは「どこに行きたいか」を伝えるだけ +- 実際に移動してよいかはサーバーが判断する +- サーバーが「世界の真実」を持つ(これを **サーバー権威型設計** という) + +--- + +## メッセージのJSON設計 + +### クライアント → サーバー(入力) + +```json +{ + "type": "input", + "direction": "up" +} +``` + +`direction` に入る値: `"up"` / `"down"` / `"left"` / `"right"` + +### サーバー → クライアント(確認) + +```json +{ + "type": "ack", + "playerId": "abc123", + "direction": "up" +} +``` + +--- + +## キーボードイベントの扱い + +```javascript +// keydown: キーを押した瞬間 +document.addEventListener('keydown', (e) => { + switch (e.key) { + case 'ArrowUp': direction = 'up'; break; + case 'ArrowDown': direction = 'down'; break; + case 'ArrowLeft': direction = 'left'; break; + case 'ArrowRight': direction = 'right'; break; + default: return; // 関係ないキーは無視 + } + // 方向が変わったらサーバーに送る + sendInput(direction); +}); +``` + +注意点: +- 毎フレーム送るのではなく、**変化したときだけ送る** +- 同じ方向を押し続けても、毎回送る必要はない(後のステップでゲームループが動かし続ける) + +--- + +## ファイル構成 + +``` +step-03-input/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## コード解説 + +### サーバー側 + +```go +// プレイヤーの状態(このステップでは入力だけ持つ) +type PlayerInput struct { + PlayerID string + Direction string +} + +// プレイヤーごとの最新入力を保持 +var playerInputs = make(map[string]PlayerInput) +``` + +各接続に対して `playerID` を UUID などで割り当て、 +受け取った入力をその `playerID` に紐付けて保存します。 + +### クライアント側 + +画面には「現在の自分の方向」と「サーバーから受け取った全プレイヤーの方向」を表示します。 +まだ座標は持ちません。 + +--- + +## ローカル起動手順 + +```bash +cd step-03-input/server +go mod tidy +go run main.go +``` + +--- + +## よくあるエラーと対処 + +### 1. キー入力が反応しない +**原因:** フォーカスが input 要素にある(テキスト入力中) +**対処:** `document.addEventListener` で登録すると常に受け取れる。または `e.preventDefault()` で矢印キーのデフォルト動作(スクロール)を防ぐ + +### 2. 同じ方向なのに何度も送信される +**原因:** 前回と同じ方向かチェックしていない +**対処:** `if (direction === lastDirection) return;` で重複送信を防ぐ + +### 3. JSON.parse エラー +**原因:** クライアントが文字列を送っているのにサーバーがJSONとして解析しようとしている +**対処:** 送信側・受信側の形式を統一する。`JSON.stringify()` と `json.Unmarshal()` の組み合わせを確認する + +--- + +## 練習問題 + +1. **基本:** 矢印キーを押して、サーバーで受け取れることを確認する +2. **応用:** `w/a/s/d` キーにも対応させる +3. **応用:** 「最後に送った方向」を画面に大きく表示する +4. **発展:** 入力を送った回数をカウントして表示する + +--- + +## 理解確認クイズ + +1. なぜ座標(x, y)をそのまま送ってはいけないのですか? +2. 「サーバー権威型設計」とは何ですか? +3. `keydown` と `keyup` の違いは何ですか?このゲームではどちらを使うべきですか? +4. プレイヤーIDはなぜ必要ですか? +5. 入力が同じ方向のとき、毎回送信すべきですか? + +--- + +## 次のステップへ + +Step 3では「入力を送れる」ようになりました。 +でも、まだサーバーはその入力を使って**何もしていません**。 + +Step 4では、入力を受けてサーバーが状態を更新し、全員に配信します。 +「クライアントは操作する人、サーバーは世界の管理者」という設計の核心です。 diff --git a/step-03-input/client/index.html b/step-03-input/client/index.html new file mode 100644 index 0000000..fb756d2 --- /dev/null +++ b/step-03-input/client/index.html @@ -0,0 +1,170 @@ + + + + + Step 3: 入力送信 + + + +

Step 3: 入力送信

+ +
切断中
+
自分のID: なし
+ +
+ + +
+ + +
⬜ 未入力
+

矢印キー(↑↓←→)を押してください。ページスクロールは e.preventDefault() で防いでいます。

+ + +

全プレイヤーの方向

+
接続待ち...
+ + + + diff --git a/step-03-input/server/go.mod b/step-03-input/server/go.mod new file mode 100644 index 0000000..9f9cedd --- /dev/null +++ b/step-03-input/server/go.mod @@ -0,0 +1,20 @@ +module step03-input + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-03-input/server/main.go b/step-03-input/server/main.go new file mode 100644 index 0000000..dd1b398 --- /dev/null +++ b/step-03-input/server/main.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。 +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client は接続と書き込みロックをまとめた構造体。 +// +// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは +// 同時に1つだけ」という制約がある。 +// broadcast() は複数の接続ハンドラ goroutine から同時に呼ばれ得るため、 +// すべての WriteMessage を writeMu で直列化する。 +type Client struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +// writeText は writeMu を取得してからメッセージを送る。 +func (c *Client) writeText(data []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +// ClientMessage はクライアントから受け取るメッセージの形式 +type ClientMessage struct { + Type string `json:"type"` // "input" + Direction string `json:"direction"` // "up" / "down" / "left" / "right" +} + +// ServerMessage はサーバーからクライアントへ送るメッセージの形式 +type ServerMessage struct { + Type string `json:"type"` // "ack" または "state" + PlayerID string `json:"playerId"` // 自分のID(ack時) + Inputs map[string]string `json:"inputs"` // 全プレイヤーの最新入力(state時) +} + +// playerInputs は各プレイヤーの最新入力方向を保持するmap +// キー: プレイヤーID(接続ごとに割り当てる) +// 値: 最新の方向 ("up" / "down" / "left" / "right") +var playerInputs = make(map[string]string) + +// clients は接続中のクライアント(プレイヤーID → Client) +var clients = make(map[string]*Client) + +var mu sync.Mutex + +// idCounter はシンプルな連番プレイヤーID生成用(mu で保護する) +var idCounter int + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + fmt.Println("サーバー起動: http://localhost:8080") + log.Fatal(e.Start(":8080")) +} + +// broadcast は全クライアントに現在の全入力状態を送る +func broadcast() { + mu.Lock() + // 現在の inputs と clients をコピー(ロック中の処理を最小限にするため) + inputsCopy := make(map[string]string, len(playerInputs)) + for id, dir := range playerInputs { + inputsCopy[id] = dir + } + clientsCopy := make(map[string]*Client, len(clients)) + for id, c := range clients { + clientsCopy[id] = c + } + mu.Unlock() + + msg := ServerMessage{ + Type: "state", + Inputs: inputsCopy, + } + data, err := json.Marshal(msg) + if err != nil { + log.Printf("broadcast: json.Marshal エラー: %v", err) + return + } + + // 各 Client の writeMu が並行 write を直列化する + for _, client := range clientsCopy { + if err := client.writeText(data); err != nil { + log.Printf("broadcast: 送信エラー: %v", err) + } + } +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // Client を作成 + client := &Client{conn: conn} + + // プレイヤーIDの割り当てと登録を同じロック内で行い、競合を防ぐ + mu.Lock() + idCounter++ + playerID := fmt.Sprintf("player-%d", idCounter) + clients[playerID] = client + playerInputs[playerID] = "right" // 初期方向 + mu.Unlock() + + log.Printf("プレイヤー接続: %s", playerID) + + // 自分のIDをクライアントに通知 + ack, err := json.Marshal(ServerMessage{ + Type: "ack", + PlayerID: playerID, + }) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := client.writeText(ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + // 切断時の後処理 + defer func() { + mu.Lock() + delete(clients, playerID) + delete(playerInputs, playerID) + mu.Unlock() + log.Printf("プレイヤー切断: %s", playerID) + }() + + // メッセージ受信ループ + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + + var msg ClientMessage + if err := json.Unmarshal(raw, &msg); err != nil { + log.Printf("JSON解析エラー: %v", err) + continue + } + + // 入力を受け取ったら処理 + if msg.Type == "input" { + // 有効な方向かチェック + if msg.Direction == "up" || msg.Direction == "down" || + msg.Direction == "left" || msg.Direction == "right" { + + mu.Lock() + playerInputs[playerID] = msg.Direction + mu.Unlock() + + log.Printf("%s の入力: %s", playerID, msg.Direction) + + // 全員に現在の入力状態を配信 + broadcast() + } + } + } + + return nil +} diff --git a/step-04-server-state/README.md b/step-04-server-state/README.md new file mode 100644 index 0000000..8213bb3 --- /dev/null +++ b/step-04-server-state/README.md @@ -0,0 +1,195 @@ +# Step 4: サーバーで状態を持つ + +## このステップの目標 + +- サーバーが各プレイヤーの `x, y, direction` を保持する +- クライアントは入力だけ送る +- サーバーが状態を更新して全員に配信する +- クライアントはCanvasに描画するだけ + +**このステップが全カリキュラムの核心です。** + +--- + +## 超重要:「サーバーが真実を持つ」とは + +### クライアントとサーバーの役割分担 + +``` +クライアントの役割: + - キー入力をサーバーに送る + - サーバーから受け取った状態を画面に表示する + - それだけ + +サーバーの役割: + - 入力を受け取る + - ゲームの状態(全プレイヤーの位置など)を更新する + - 全クライアントに最新状態を配信する + - それが真実 +``` + +### なぜこの設計が重要か + +もしクライアントが自分で座標を計算して送っていたら: + +``` +プレイヤーAのブラウザ: 「私は x=100 y=200 にいます」 +プレイヤーBのブラウザ: 「私は x=102 y=198 にいます」 +(微妙にズレている) + +→ 衝突判定はどちらのブラウザがやる? +→ チートしたプレイヤーが好き勝手な座標を送ったら? +→ ネットワーク遅延でどちらの位置情報が「正しい」のか分からない +``` + +サーバーが全状態を持てば: + +``` +全員が「サーバーの状態」を表示するだけ +→ 全員が同じ世界を見ている +→ 衝突判定もサーバーだけがやればいい +→ チートしても無駄(サーバーが無視する) +``` + +--- + +## データ構造 + +### プレイヤー状態 + +```go +type Player struct { + ID string `json:"id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Direction string `json:"direction"` // "up" / "down" / "left" / "right" + Color string `json:"color"` // 識別用の色 +} +``` + +### サーバーが持つゲーム状態 + +```go +type GameState struct { + Players map[string]*Player +} +``` + +### 通信メッセージ設計 + +クライアント → サーバー: +```json +{ "type": "input", "direction": "up" } +``` + +サーバー → クライアント(状態更新): +```json +{ + "type": "state", + "players": { + "player-1": { "id": "player-1", "x": 100, "y": 200, "direction": "up", "color": "#ff6b6b" }, + "player-2": { "id": "player-2", "x": 300, "y": 150, "direction": "right", "color": "#4ecdc4" } + } +} +``` + +--- + +## Canvas 描画 + +```javascript +// Canvas を使ってプレイヤーを丸で描く +function draw(state) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (const player of Object.values(state.players)) { + ctx.beginPath(); + ctx.arc(player.x, player.y, 15, 0, Math.PI * 2); + ctx.fillStyle = player.color; + ctx.fill(); + + // 名前を表示 + ctx.fillStyle = 'black'; + ctx.fillText(player.id, player.x - 20, player.y - 20); + } +} +``` + +--- + +## ファイル構成 + +``` +step-04-server-state/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## 動作確認の手順 + +1. サーバーを起動 +2. タブ1でブラウザを開いて接続 +3. タブ2でもブラウザを開いて接続 +4. どちらのタブでも矢印キーを押す +5. **両方のタブで同じ位置情報が表示されること**を確認する + +--- + +## よくある設計ミス + +### ミス1: クライアントがサーバーに座標を送る +```json +// ❌ これをやらない +{ "type": "move", "x": 150, "y": 200 } +``` +→ チート可能になる。サーバーが何でも受け入れてしまう。 + +### ミス2: 状態更新をクライアントがやる +```javascript +// ❌ これもダメ +player.x += 1; // クライアントで移動計算する +ws.send({ x: player.x, y: player.y }); // 結果を送る +``` +→ 各クライアントが独自に計算するので、ズレが生じる。 + +### ミス3: ミューテックスを使わない +```go +// ❌ 競合が起きる +players[id] = newPlayer // 複数goroutineから同時に書き込む +``` +→ `sync.Mutex` や `sync.RWMutex` で保護する。 + +--- + +## 練習問題 + +1. **基本:** 複数タブで接続して、全員の位置がCanvasに表示されることを確認する +2. **応用:** プレイヤーの移動速度を変える定数を追加する +3. **応用:** 画面外に出たらもう一方から出てくる(ラップアラウンド)処理を追加する +4. **発展:** プレイヤーが入退室したとき、残りのプレイヤーに通知を送る + +--- + +## 理解確認クイズ + +1. なぜクライアントは座標を計算してはいけないのですか? +2. `sync.Mutex` は何を防ぐためのものですか? +3. サーバーが状態を保持するメリットを3つ挙げてください +4. このステップでクライアントが「やること」は何ですか?(2つ) +5. 「サーバー権威型設計」を知らない人に、一言で説明してください + +--- + +## 次のステップへ + +Step 4では「サーバーが状態を持つ」を実装しました。 +でも今の実装は、入力があったときだけ位置を更新しています。 + +蛇ゲームでは「キーを押していなくても蛇は動き続ける」必要があります。 +Step 5では、一定周期でゲームを進める**ゲームループ**を導入します。 diff --git a/step-04-server-state/client/index.html b/step-04-server-state/client/index.html new file mode 100644 index 0000000..8505751 --- /dev/null +++ b/step-04-server-state/client/index.html @@ -0,0 +1,180 @@ + + + + + Step 4: サーバー状態管理 + + + +

Step 4: サーバー状態管理

+ +
切断中
+
自分のID: なし
+ +
+ + +
+ + + +

矢印キーで移動 / 接続を複数タブで開いて全員の位置が同期されることを確認する

+ + + + diff --git a/step-04-server-state/server/go.mod b/step-04-server-state/server/go.mod new file mode 100644 index 0000000..75dd51c --- /dev/null +++ b/step-04-server-state/server/go.mod @@ -0,0 +1,20 @@ +module step04-server-state + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-04-server-state/server/main.go b/step-04-server-state/server/main.go new file mode 100644 index 0000000..4dd264f --- /dev/null +++ b/step-04-server-state/server/main.go @@ -0,0 +1,244 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。 +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client は接続と書き込みロックをまとめた構造体。 +// +// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは +// 同時に1つだけ」という制約がある。 +// broadcast() は複数の接続ハンドラ goroutine から同時に呼ばれ得るため、 +// すべての WriteMessage を writeMu で直列化する。 +type Client struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +// writeText は writeMu を取得してからメッセージを送る。 +func (c *Client) writeText(data []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +// Player はプレイヤーの状態を表す構造体 +// サーバーがこれを保持し、クライアントはこれを受け取って表示するだけ +type Player struct { + ID string `json:"id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Direction string `json:"direction"` + Color string `json:"color"` +} + +// ClientMessage はクライアントから受け取るメッセージ +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +// ServerMessage はクライアントへ送るメッセージ +type ServerMessage struct { + Type string `json:"type"` + Players map[string]*Player `json:"players"` + MyID string `json:"myId,omitempty"` // 自分のIDをack時に通知 +} + +// ゲームのグローバル状態 +// サーバーが「真実」として保持する +var ( + players = make(map[string]*Player) + clients = make(map[string]*Client) + mu sync.RWMutex + counter int +) + +// プレイヤーに割り当てる色のリスト +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", + "#a29bfe", "#fd79a8", +} + +// 移動速度(1tick あたりのピクセル数) +const moveSpeed = 5.0 + +// フィールドサイズ +const fieldWidth = 800.0 +const fieldHeight = 600.0 + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + fmt.Println("サーバー起動: http://localhost:8080") + log.Fatal(e.Start(":8080")) +} + +// broadcast は現在のゲーム状態を全クライアントに送信する +func broadcast() { + mu.RLock() + // 状態のスナップショットを作成 + playersCopy := make(map[string]*Player, len(players)) + for id, p := range players { + copy := *p + playersCopy[id] = © + } + clientsCopy := make(map[string]*Client, len(clients)) + for id, c := range clients { + clientsCopy[id] = c + } + mu.RUnlock() + + msg := ServerMessage{ + Type: "state", + Players: playersCopy, + } + data, err := json.Marshal(msg) + if err != nil { + log.Printf("broadcast: json.Marshal エラー: %v", err) + return + } + + // 各 Client の writeMu が並行 write を直列化する + for _, client := range clientsCopy { + if err := client.writeText(data); err != nil { + log.Printf("broadcast: 送信エラー: %v", err) + } + } +} + +// movePlayer は入力方向に応じてプレイヤーを移動させる +// これがサーバー側の「真実の更新」 +func movePlayer(p *Player) { + switch p.Direction { + case "up": + p.Y -= moveSpeed + case "down": + p.Y += moveSpeed + case "left": + p.X -= moveSpeed + case "right": + p.X += moveSpeed + } + + // 画面外に出たら反対側から出てくる(ラップアラウンド) + if p.X < 0 { + p.X = fieldWidth + } + if p.X > fieldWidth { + p.X = 0 + } + if p.Y < 0 { + p.Y = fieldHeight + } + if p.Y > fieldHeight { + p.Y = 0 + } +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // Client を作成 + client := &Client{conn: conn} + + // プレイヤーIDと色を割り当てる + mu.Lock() + counter++ + playerID := fmt.Sprintf("player-%d", counter) + colorIdx := (counter - 1) % len(playerColors) + + // 新しいプレイヤーを登録(サーバーが初期状態を決める) + player := &Player{ + ID: playerID, + X: float64(100 + (counter-1)*50), // 少しずらして配置 + Y: 300, + Direction: "right", + Color: playerColors[colorIdx], + } + players[playerID] = player + clients[playerID] = client + mu.Unlock() + + log.Printf("プレイヤー接続: %s", playerID) + + // 自分のIDを通知する + ack, err := json.Marshal(ServerMessage{ + Type: "state", + MyID: playerID, + Players: map[string]*Player{}, + }) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := client.writeText(ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + // 現在の状態を全員にブロードキャスト + broadcast() + + // 切断時の処理 + defer func() { + mu.Lock() + delete(players, playerID) + delete(clients, playerID) + mu.Unlock() + log.Printf("プレイヤー切断: %s", playerID) + broadcast() + }() + + // メッセージ受信ループ + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + + var msg ClientMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + if msg.Type == "input" { + // 入力を受け取ったら、サーバーが状態を更新する + if msg.Direction == "up" || msg.Direction == "down" || + msg.Direction == "left" || msg.Direction == "right" { + + mu.Lock() + if p, ok := players[playerID]; ok { + // 方向を更新して即座に1歩移動 + p.Direction = msg.Direction + movePlayer(p) + } + mu.Unlock() + + // 全員に最新状態を配信 + broadcast() + } + } + } + + return nil +} diff --git a/step-05-game-loop/README.md b/step-05-game-loop/README.md new file mode 100644 index 0000000..4d0a44f --- /dev/null +++ b/step-05-game-loop/README.md @@ -0,0 +1,172 @@ +# Step 5: ゲームループを入れる + +## このステップの目標 + +- サーバー側にtickベースのゲームループを実装する +- プレイヤーはキーを押さなくても動き続ける +- 入力受付と状態更新を分離する + +--- + +## 概念:イベント駆動 vs ゲームループ + +### Step 4 までの設計(イベント駆動) + +``` +キー入力 → サーバーへ送信 → サーバーが状態更新 → 全員に配信 +``` + +問題点: +- **キーを押さないと何も起きない** +- 蛇ゲームでは「ずっと前進し続ける」が必要 +- 入力がないとブロードキャストも止まる + +### ゲームループ駆動の設計 + +``` +「入力の受付」と「ゲームの進行」を分離する + +入力受付(いつでも): + キー入力 → サーバーへ送信 → 「方向」だけ更新 + +ゲーム進行(一定周期): + 毎100ms → 全プレイヤーを前進 → 全員に配信 +``` + +--- + +## update と broadcast の分離 + +```go +// 100ms ごとにゲームを進める +func gameLoop() { + ticker := time.NewTicker(100 * time.Millisecond) + for { + <-ticker.C + updateGame() // ゲーム状態を更新 + broadcastState() // 全員に送信 + } +} + +// ゲーム状態の更新(物理演算・衝突判定など、将来ここが増える) +func updateGame() { + for _, player := range players { + movePlayer(player) // 方向に従って前進 + } +} + +// 状態の送信(描画用データを送るだけ) +func broadcastState() { + // 全員に現在の状態を送る +} +``` + +--- + +## tickレート + +tick レートとは「1秒に何回ゲームを更新するか」です。 + +| tick間隔 | 1秒あたりの更新回数 | 用途 | +|---|---|---| +| 1000ms | 1回 | 非常に低速 | +| 100ms | 10回 | 学習用、低速ゲーム | +| 50ms | 20回 | 普通のゲーム | +| 33ms | 30回 | 滑らかなゲーム | +| 16ms | 60回 | FPS系ゲーム | + +この教材では **100ms(10tick/秒)** で始めます。 +後のステップで遅延を体験するときに値を変えてみます。 + +--- + +## ファイル構成 + +``` +step-05-game-loop/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## Step 4 からの変更点 + +サーバー: +- `go gameLoop()` でゲームループをバックグラウンドで起動 +- `time.NewTicker` で一定周期の処理を実現 +- 入力受付ハンドラはもう `broadcast` しない(ゲームループが定期的にやる) + +クライアント: +- 基本的に変わらない +- ただし「受け取った状態を描画するだけ」がより明確になる + +--- + +## よくある間違い + +### 1. 入力受付のたびにbroadcastする +```go +// △ 悪くはないが、ゲームループと二重になる可能性がある +if msg.Type == "input" { + updateDirection(playerID, msg.Direction) + broadcastState() // ← ゲームループでもやっているので不要 +} +``` + +### 2. goroutine を起動しすぎる +```go +// ❌ 接続のたびにゲームループを起動してしまう +func handleWebSocket(...) { + go gameLoop() // これだと接続ごとにループが増える +} +``` +ゲームループは `main()` で1回だけ起動する。 + +### 3. ticker のリークを防ぐ +```go +ticker := time.NewTicker(100 * time.Millisecond) +defer ticker.Stop() // 必ず止める +``` + +--- + +## 動作確認 + +1. サーバーを起動 +2. ブラウザで接続する +3. **キーを押さなくてもプレイヤーが動き続けること**を確認する(右方向に自走する) +4. 矢印キーで方向を変えると、即座に方向が変わることを確認する + +--- + +## 練習問題 + +1. **基本:** ゲームループが動いていることをサーバーのログで確認する +2. **応用:** tick 間隔を 500ms, 100ms, 33ms で比較して、動きの違いを観察する +3. **応用:** 「tickカウンター」を画面に表示する(何tick目かを表示) +4. **発展:** プレイヤーが接続していないときはゲームループを止める(最適化) + +--- + +## 理解確認クイズ + +1. ゲームループがないと何が問題ですか? +2. `time.NewTicker(100 * time.Millisecond)` は何をしますか? +3. 入力受付とゲーム更新を「分離」するとはどういう意味ですか? +4. tick レートを上げると、何が変わりますか?デメリットは? +5. `go gameLoop()` の `go` キーワードは何をしていますか? + +--- + +## 次のステップへ + +Step 5で「時間で動く世界」ができました。 +Step 6ではこれを蛇ゲームに拡張します。 + +プレイヤーが「丸1つ」から「体を持つ蛇」に変わります。 +データ構造が変わるので、ネットワークに流すデータも変わってきます。 diff --git a/step-05-game-loop/client/index.html b/step-05-game-loop/client/index.html new file mode 100644 index 0000000..61b7928 --- /dev/null +++ b/step-05-game-loop/client/index.html @@ -0,0 +1,167 @@ + + + + + Step 5: ゲームループ + + + +

Step 5: ゲームループ

+ +
+ + +
+ +
+ 切断中 + 自分のID: なし + Tick: 0 + 最終受信: - +
+ + +

接続するとキーを押さなくても右方向に自走します。矢印キーで方向を変えてください。

+ + + + diff --git a/step-05-game-loop/server/go.mod b/step-05-game-loop/server/go.mod new file mode 100644 index 0000000..f2df810 --- /dev/null +++ b/step-05-game-loop/server/go.mod @@ -0,0 +1,20 @@ +module step05-game-loop + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-05-game-loop/server/main.go b/step-05-game-loop/server/main.go new file mode 100644 index 0000000..dc5cb4a --- /dev/null +++ b/step-05-game-loop/server/main.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。 +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client は接続と書き込みロックをまとめた構造体。 +// +// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは +// 同時に1つだけ」という制約がある。 +// gameLoop の broadcastState と handleWebSocket の ack 送信が +// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。 +type Client struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +// writeText は writeMu を取得してからメッセージを送る。 +func (c *Client) writeText(data []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +// Player はプレイヤーの状態 +type Player struct { + ID string `json:"id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Direction string `json:"direction"` + Color string `json:"color"` +} + +// ClientMessage はクライアントからの入力 +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +// ServerMessage はクライアントへのゲーム状態 +type ServerMessage struct { + Type string `json:"type"` + Players map[string]*Player `json:"players"` + TickCount int64 `json:"tickCount"` // 何tick目か(デバッグ用) + MyID string `json:"myId,omitempty"` +} + +var ( + players = make(map[string]*Player) + clients = make(map[string]*Client) + mu sync.RWMutex + counter int + tickCount int64 +) + +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", +} + +const ( + moveSpeed = 4.0 + fieldWidth = 800.0 + fieldHeight = 600.0 + tickRate = 100 * time.Millisecond // 100ms = 10 tick/秒 +) + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + // ゲームループをバックグラウンドで起動(main で1回だけ) + go gameLoop() + + fmt.Println("サーバー起動: http://localhost:8080") + fmt.Printf("tick レート: %v\n", tickRate) + log.Fatal(e.Start(":8080")) +} + +// gameLoop はゲームの心臓部:一定周期で状態を更新して配信する +func gameLoop() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + + for { + <-ticker.C // ticker がカウントするたびにここを通過する + + updateGame() // ゲームの状態を更新 + broadcastState() // 全クライアントに配信 + } +} + +// updateGame は毎 tick 呼ばれる状態更新処理 +// 「物理エンジン」に相当する部分 +func updateGame() { + mu.Lock() + defer mu.Unlock() + + tickCount++ + + for _, player := range players { + movePlayer(player) + } +} + +// movePlayer はプレイヤーを1 tick 分前進させる +func movePlayer(p *Player) { + switch p.Direction { + case "up": + p.Y -= moveSpeed + case "down": + p.Y += moveSpeed + case "left": + p.X -= moveSpeed + case "right": + p.X += moveSpeed + } + + // ラップアラウンド(画面の端を超えたら反対側へ) + if p.X < 0 { + p.X = fieldWidth + } + if p.X > fieldWidth { + p.X = 0 + } + if p.Y < 0 { + p.Y = fieldHeight + } + if p.Y > fieldHeight { + p.Y = 0 + } +} + +// broadcastState は全クライアントにゲーム状態を送信する +func broadcastState() { + mu.RLock() + playersCopy := make(map[string]*Player, len(players)) + for id, p := range players { + cp := *p + playersCopy[id] = &cp + } + clientsCopy := make(map[string]*Client, len(clients)) + for id, c := range clients { + clientsCopy[id] = c + } + tick := tickCount + mu.RUnlock() + + if len(clientsCopy) == 0 { + return // 接続中のクライアントがいなければ何もしない + } + + msg := ServerMessage{ + Type: "state", + Players: playersCopy, + TickCount: tick, + } + data, err := json.Marshal(msg) + if err != nil { + log.Printf("broadcastState: json.Marshal エラー: %v", err) + return + } + + for _, client := range clientsCopy { + if err := client.writeText(data); err != nil { + log.Printf("broadcastState: 送信エラー: %v", err) + } + } +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // Client を作成 + client := &Client{conn: conn} + + // プレイヤー登録 + mu.Lock() + counter++ + playerID := fmt.Sprintf("player-%d", counter) + colorIdx := (counter - 1) % len(playerColors) + player := &Player{ + ID: playerID, + X: float64(100 + ((counter-1)%8)*80), + Y: 300, + Direction: "right", + Color: playerColors[colorIdx], + } + players[playerID] = player + clients[playerID] = client + mu.Unlock() + + log.Printf("プレイヤー接続: %s", playerID) + + // 自分のIDを通知 + ack, err := json.Marshal(ServerMessage{ + Type: "state", + MyID: playerID, + Players: map[string]*Player{}, + }) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := client.writeText(ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + // 切断処理 + defer func() { + mu.Lock() + delete(players, playerID) + delete(clients, playerID) + mu.Unlock() + log.Printf("プレイヤー切断: %s", playerID) + }() + + // 入力受付ループ(ゲームループとは独立して動く) + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + + var msg ClientMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + // 入力を受け取ったら「方向」だけ更新する + // 実際の移動はゲームループが次のtickでやる + if msg.Type == "input" { + if msg.Direction == "up" || msg.Direction == "down" || + msg.Direction == "left" || msg.Direction == "right" { + mu.Lock() + if p, ok := players[playerID]; ok { + p.Direction = msg.Direction // 方向だけ更新 + } + mu.Unlock() + // broadcastはしない!ゲームループが定期的にやる + } + } + } + + return nil +} diff --git a/step-06-snake/README.md b/step-06-snake/README.md new file mode 100644 index 0000000..4cc91c4 --- /dev/null +++ b/step-06-snake/README.md @@ -0,0 +1,200 @@ +# Step 6: 蛇ゲームにする + +## このステップの目標 + +- プレイヤーが「点」から「体を持つ蛇」になる +- エサを食べると伸びる +- 他プレイヤーの蛇も表示される + +--- + +## 蛇の表現:body配列 + +蛇は「頭 + 胴体のリスト」で表現します。 + +``` +蛇の初期状態(右向き、3マス): + [x:50,y:100] [x:40,y:100] [x:30,y:100] + ↑頭(先頭) ↑尾(末尾) + +1 tick 後(右に進む): + [x:60,y:100] [x:50,y:100] [x:40,y:100] + ↑新しい頭 ↑元の頭が胴体に ↑末尾は削除 +``` + +### 移動のアルゴリズム + +``` +1. 頭の新しい座標を計算する(方向に従って1歩進む) +2. 新しい頭を配列の先頭に追加する(unshift) +3. 配列の末尾を削除する(pop) + → 長さが変わらない = 通常の移動 + +4. エサを食べたとき: + 3の「末尾を削除」をスキップする + → 長さが1増える = 成長 +``` + +--- + +## データモデル + +### サーバー側 + +```go +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type Snake struct { + ID string `json:"id"` + Body []Point `json:"body"` // body[0] が頭 + Direction string `json:"direction"` + Color string `json:"color"` + Alive bool `json:"alive"` +} + +type Food struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type GameState struct { + Snakes map[string]*Snake `json:"snakes"` + Foods []Food `json:"foods"` +} +``` + +--- + +## グリッドベース設計 + +蛇ゲームはグリッド(マス目)で動かすと実装が楽です。 + +``` +グリッドサイズ: 20px × 20px +フィールド: 800px × 600px = 40 × 30 マス + +蛇の1マスは 20×20 px の正方形 +エサも同じグリッド上に配置 +``` + +グリッドに合わせることで: +- 衝突判定が「同じグリッド座標か?」だけで済む +- 蛇の体が綺麗に並ぶ + +--- + +## エサのシステム + +```go +// エサをランダムな位置に配置する +func spawnFood() Food { + x := float64(rand.Intn(fieldWidth/gridSize)) * gridSize + y := float64(rand.Intn(fieldHeight/gridSize)) * gridSize + return Food{X: x, Y: y} +} +``` + +--- + +## ファイル構成 + +``` +step-06-snake/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## このステップではやらないこと + +- 壁への衝突判定(蛇が壁にぶつかって死ぬ) +- 自分の体への衝突判定 +- 他プレイヤーへの衝突判定 + +これらは後のステップで追加します。 +まずは「蛇が動いてエサを食べて伸びる」を動かします。 + +--- + +## よくある不具合と対処 + +### 1. 蛇がテレポートする +**原因:** 移動量がグリッドサイズと合っていない +**対処:** `moveSpeed = gridSize` にする(1tickで1マス進む) + +### 2. 体が重なる +**原因:** 方向転換の処理が間違っている +**対処:** 180度の方向転換(上→下、左→右)を禁止する処理を追加 + +```go +// 反対方向への転換を禁止 +func isOppositeDirection(current, next string) bool { + opposites := map[string]string{ + "up": "down", "down": "up", + "left": "right", "right": "left", + } + return opposites[current] == next +} +``` + +### 3. エサが蛇の体の上に生成される +**対処:** エサ生成時に蛇の体の座標リストを確認して、被らない位置を探す + +--- + +## Canvas 描画 + +```javascript +// 蛇を描画 +function drawSnake(snake) { + for (let i = 0; i < snake.body.length; i++) { + const segment = snake.body[i]; + const isHead = i === 0; + + ctx.fillStyle = isHead ? snake.color : adjustColor(snake.color, -30); + ctx.fillRect( + segment.x + 1, // 1pxのギャップで各セグメントを区別 + segment.y + 1, + gridSize - 2, + gridSize - 2 + ); + } +} +``` + +--- + +## 練習問題 + +1. **基本:** 蛇が動いてエサを食べて伸びることを確認する +2. **応用:** 180度転換を禁止する(逆方向への転換を無効にする) +3. **応用:** エサを複数(3つなど)配置する +4. **発展:** スコア(食べたエサの数)を表示する + +--- + +## 理解確認クイズ + +1. 蛇の移動を「body配列の先頭追加 + 末尾削除」で表現する理由は何ですか? +2. 成長するときと通常移動の違いは何ですか?(コードレベルで) +3. なぜグリッドベースにすると衝突判定が楽になるのですか? +4. このステップでネットワークに流れるデータ量は Step 5 より多いですか?なぜ? +5. 「蛇の頭」は `body` 配列のどのインデックスですか? + +--- + +## 次のステップへ + +Step 6で蛇ゲームの形になりました。 +ここで「10人プレイするとデータ量が増える」ことも体感できたはずです。 + +Step 7では、わざと遅延を入れてリアルタイム通信の問題を体験します。 +「WebSocketを使えば遅延はない」という誤解を潰します。 diff --git a/step-06-snake/client/index.html b/step-06-snake/client/index.html new file mode 100644 index 0000000..31927fb --- /dev/null +++ b/step-06-snake/client/index.html @@ -0,0 +1,254 @@ + + + + + Step 6: 蛇ゲーム + + + +

Step 6: 蛇ゲーム

+ +
+ + +
+ +
+ 切断中 + 自分: なし + スコア: 0 + プレイヤー数: 0 +
+ + +

矢印キーで方向転換(180度転換は禁止) / 複数タブで接続してみてください

+
+ + + + diff --git a/step-06-snake/server/go.mod b/step-06-snake/server/go.mod new file mode 100644 index 0000000..bf15444 --- /dev/null +++ b/step-06-snake/server/go.mod @@ -0,0 +1,20 @@ +module step06-snake + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-06-snake/server/main.go b/step-06-snake/server/main.go new file mode 100644 index 0000000..60c45ec --- /dev/null +++ b/step-06-snake/server/main.go @@ -0,0 +1,350 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。 +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client は接続と書き込みロックをまとめた構造体。 +// +// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは +// 同時に1つだけ」という制約がある。 +// gameLoop の broadcastState と handleWebSocket の ack 送信が +// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。 +type Client struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +// writeText は writeMu を取得してからメッセージを送る。 +func (c *Client) writeText(data []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +// フィールドとグリッドの設定 +const ( + fieldWidth = 800 + fieldHeight = 600 + gridSize = 20 // 1マスのサイズ(px) + tickRate = 150 * time.Millisecond // 1秒あたり約7tick(蛇ゲームらしい速度) + initialLen = 5 // 蛇の初期の長さ(マス数) + foodCount = 5 // フィールド上のエサ数 +) + +// Point はグリッド上の1点 +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// Snake は1匹の蛇 +type Snake struct { + ID string `json:"id"` + Body []Point `json:"body"` // body[0] が頭 + Direction string `json:"direction"` // "up"/"down"/"left"/"right" + Color string `json:"color"` + Score int `json:"score"` // 食べたエサ数 +} + +// Food はエサ +type Food struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// ClientMessage はクライアントから受け取るメッセージ +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +// GameState はゲーム全体の状態(これをクライアントに送る) +type GameState struct { + Type string `json:"type"` + Snakes map[string]*Snake `json:"snakes"` + Foods []Food `json:"foods"` + MyID string `json:"myId,omitempty"` +} + +var ( + snakes = make(map[string]*Snake) + clients = make(map[string]*Client) + foods []Food + mu sync.RWMutex + counter int +) + +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", + "#a29bfe", "#fd79a8", +} + +func main() { + // エサを初期配置 + for i := 0; i < foodCount; i++ { + foods = append(foods, spawnFood()) + } + + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + go gameLoop() + + fmt.Println("サーバー起動: http://localhost:8080") + log.Fatal(e.Start(":8080")) +} + +// spawnFood はランダムな位置にエサを生成する(グリッドに合わせる) +func spawnFood() Food { + cols := fieldWidth / gridSize + rows := fieldHeight / gridSize + return Food{ + X: float64(rand.Intn(cols) * gridSize), + Y: float64(rand.Intn(rows) * gridSize), + } +} + +// createSnake は新しい蛇を初期化する +func createSnake(id, color string, startX, startY float64) *Snake { + body := make([]Point, initialLen) + // 右向きで配置(頭が右、尻尾が左) + for i := 0; i < initialLen; i++ { + body[i] = Point{X: startX - float64(i)*gridSize, Y: startY} + } + return &Snake{ + ID: id, + Body: body, + Direction: "right", + Color: color, + Score: 0, + } +} + +// isOppositeDirection は反対方向への転換かどうかをチェックする +func isOppositeDirection(current, next string) bool { + opposites := map[string]string{ + "up": "down", "down": "up", + "left": "right", "right": "left", + } + return opposites[current] == next +} + +// gameLoop はメインのゲームループ +func gameLoop() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + + for { + <-ticker.C + updateGame() + broadcastState() + } +} + +// updateGame は毎 tick ゲーム状態を更新する +func updateGame() { + mu.Lock() + defer mu.Unlock() + + for _, snake := range snakes { + moveSnake(snake) + } +} + +// moveSnake は蛇を1マス前進させる +func moveSnake(snake *Snake) { + if len(snake.Body) == 0 { + return + } + + // 現在の頭の位置 + head := snake.Body[0] + + // 方向に従って新しい頭の座標を計算 + var newHead Point + switch snake.Direction { + case "up": + newHead = Point{X: head.X, Y: head.Y - gridSize} + case "down": + newHead = Point{X: head.X, Y: head.Y + gridSize} + case "left": + newHead = Point{X: head.X - gridSize, Y: head.Y} + case "right": + newHead = Point{X: head.X + gridSize, Y: head.Y} + } + + // ラップアラウンド(画面外に出たら反対側へ) + if newHead.X < 0 { + newHead.X = float64(fieldWidth - gridSize) + } + if newHead.X >= fieldWidth { + newHead.X = 0 + } + if newHead.Y < 0 { + newHead.Y = float64(fieldHeight - gridSize) + } + if newHead.Y >= fieldHeight { + newHead.Y = 0 + } + + // 新しい頭を先頭に追加 + snake.Body = append([]Point{newHead}, snake.Body...) + + // エサとの衝突チェック + ate := false + for i, food := range foods { + if food.X == newHead.X && food.Y == newHead.Y { + // エサを食べた! + snake.Score++ + ate = true + // 食べたエサを新しいエサに置き換える + foods[i] = spawnFood() + break + } + } + + if !ate { + // 食べていなければ尻尾を削除(長さを維持) + snake.Body = snake.Body[:len(snake.Body)-1] + } + // 食べていれば尻尾を削除しない(長さが1増える = 成長) +} + +// broadcastState は全クライアントにゲーム状態を送信する +func broadcastState() { + mu.RLock() + snakesCopy := make(map[string]*Snake, len(snakes)) + for id, s := range snakes { + sCopy := *s + bodyCopy := make([]Point, len(s.Body)) + copy(bodyCopy, s.Body) + sCopy.Body = bodyCopy + snakesCopy[id] = &sCopy + } + foodsCopy := make([]Food, len(foods)) + copy(foodsCopy, foods) + clientsCopy := make(map[string]*Client, len(clients)) + for id, c := range clients { + clientsCopy[id] = c + } + mu.RUnlock() + + if len(clientsCopy) == 0 { + return + } + + msg := GameState{ + Type: "state", + Snakes: snakesCopy, + Foods: foodsCopy, + } + data, err := json.Marshal(msg) + if err != nil { + log.Printf("broadcastState: json.Marshal エラー: %v", err) + return + } + + for _, client := range clientsCopy { + if err := client.writeText(data); err != nil { + log.Printf("broadcastState: 送信エラー: %v", err) + } + } +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // Client を作成 + client := &Client{conn: conn} + + mu.Lock() + counter++ + snakeID := fmt.Sprintf("snake-%d", counter) + colorIdx := (counter - 1) % len(playerColors) + + // 初期位置をずらして配置 + cols := fieldWidth / gridSize + startCol := ((counter - 1) * 8) % (cols - initialLen) + startX := float64((startCol + initialLen) * gridSize) + startY := float64(((counter-1)*5)%(fieldHeight/gridSize-1) * gridSize + gridSize) + + snake := createSnake(snakeID, playerColors[colorIdx], startX, startY) + snakes[snakeID] = snake + clients[snakeID] = client + mu.Unlock() + + log.Printf("蛇が接続: %s", snakeID) + + // 自分のIDを通知 + ack, err := json.Marshal(GameState{ + Type: "state", + MyID: snakeID, + Snakes: map[string]*Snake{}, + Foods: []Food{}, + }) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := client.writeText(ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + defer func() { + mu.Lock() + delete(snakes, snakeID) + delete(clients, snakeID) + mu.Unlock() + log.Printf("蛇が切断: %s", snakeID) + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + + var msg ClientMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + if msg.Type == "input" { + if msg.Direction == "up" || msg.Direction == "down" || + msg.Direction == "left" || msg.Direction == "right" { + mu.Lock() + if s, ok := snakes[snakeID]; ok { + // 反対方向への転換は禁止 + if !isOppositeDirection(s.Direction, msg.Direction) { + s.Direction = msg.Direction + } + } + mu.Unlock() + } + } + } + + return nil +} diff --git a/step-07-lag/README.md b/step-07-lag/README.md new file mode 100644 index 0000000..8e3a958 --- /dev/null +++ b/step-07-lag/README.md @@ -0,0 +1,174 @@ +# Step 7: 遅延と同期ズレを体験する + +## このステップの目標 + +- WebSocketを使っても遅延が発生することを体験する +- カクつき・ワープの原因を理解する +- 「TCPだから安心」が遅延ゼロを意味しない理由を理解する + +--- + +## 大事な事実 + +WebSocketはTCPベースです。TCPには以下の特徴があります。 + +``` +TCPが保証してくれること: + ✓ パケットが「届く」こと(再送制御) + ✓ パケットの「順序」が正しいこと + +TCPが保証「しない」こと: + ✗ パケットが「速く届く」こと + ✗ パケットが「定期的に届く」こと +``` + +つまり、データは必ず届くが、いつ届くかはネットワーク状況次第です。 + +--- + +## 遅延の種類 + +### 1. 固定遅延(Propagation Delay) +``` +物理的な距離による遅延 +東京 ↔ 大阪: ~3ms +東京 ↔ ニューヨーク: ~100ms +``` + +### 2. ジッター(Jitter) +``` +遅延のばらつき +100ms, 102ms, 98ms, 150ms, 103ms... +↑ 突然跳ね上がることがある(パケットロスからの再送など) +``` + +### 3. パケットロスと再送 +``` +ルーターの混雑でパケットが失われる +→ TCPが自動で再送する +→ その間、受信側は待ち続ける +→ 突然まとめて届いてワープしたように見える +``` + +### 4. アプリケーション遅延 +``` +サーバー側の処理時間 +tick間隔(100ms ごとにしか送らない) +``` + +--- + +## この実験でやること + +**意図的に遅延と揺らぎを入れて、カクつきを観察する。** + +### 実験1: tick間隔を変える +```go +// 通常 +tickRate = 100 * time.Millisecond + +// 遅くする +tickRate = 500 * time.Millisecond + +// さらに遅く + ランダムな揺らぎ +tickRate = 100 * time.Millisecond +// + rand.Intn(400) ms のランダム遅延 +``` + +### 実験2: クライアントで受信間隔を計測する +```javascript +let lastReceived = null; +ws.onmessage = (event) => { + const now = Date.now(); + if (lastReceived) { + console.log(`受信間隔: ${now - lastReceived}ms`); + } + lastReceived = now; +}; +``` + +--- + +## ファイル構成 + +``` +step-07-lag/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## 観察ポイント + +| 設定 | 観察されること | +|---|---| +| tick 100ms(通常) | ほぼ滑らかに動く | +| tick 500ms | カクカク動く(コマ送り) | +| tick 100ms + ランダム遅延 | 時々ワープする | +| tick 100ms(クライアントの描画は多い) | 補間なしだとカクつく | + +--- + +## 「ワープ」が起きる仕組み + +``` +時刻 0ms: 位置 x=100 を受信 → 描画 +時刻 100ms: 次の状態を受信するはずが... +時刻 350ms: やっと受信(250ms 遅延) + この間、蛇は100の位置のまま止まっている + 受信したら x=200 に描画 + → 100 → 200 へ瞬間移動(ワープ) +``` + +これは「補間」で解決できます(Step 8)。 + +--- + +## よくある誤解 + +**誤解:「WebSocketを使えばリアルタイムになる」** + +正確には: +- WebSocketは「常時接続」を提供する +- でも遅延はネットワーク次第 +- 「リアルタイム感」は別途工夫が必要 + +**誤解:「ローカルで試すと速いから本番でも大丈夫」** + +実際には: +- ローカル(同じPC)では遅延がほぼ0 +- インターネット越しでは数十〜数百ms +- 必ず遅延を模擬して開発する + +--- + +## 練習問題 + +1. **観察:** tick間隔を 100ms / 300ms / 1000ms で試して違いを観察する +2. **観察:** ランダム遅延を入れて、ログで受信間隔のばらつきを見る +3. **分析:** 「カクつき」と「ワープ」の違いを自分の言葉で説明する +4. **考察:** 「補間で解決できる問題」と「できない問題」を挙げてみる + +--- + +## 理解確認クイズ + +1. TCPが「保証する」ことと「保証しない」ことは何ですか? +2. 「ジッター」とは何ですか? +3. なぜローカルで動くと問題が見えないのですか? +4. パケットロスが起きるとなぜワープが発生しますか? +5. tick間隔を短くすればするほど良いですか?問題点は? + +--- + +## 次のステップへ + +Step 7では遅延の「問題」を理解しました。 +Step 8では遅延があっても「滑らかに見せる」技術(補間)を学びます。 + +「正確な位置」と「気持ちよく見える位置」は別物だと分かるはずです。 diff --git a/step-07-lag/client/index.html b/step-07-lag/client/index.html new file mode 100644 index 0000000..7fb03eb --- /dev/null +++ b/step-07-lag/client/index.html @@ -0,0 +1,272 @@ + + + + + Step 7: 遅延体験 + + + +

Step 7: 遅延と同期ズレを体験する

+ +
+ + +
+ + + + +
+
状態: 切断中
+
Tick: 0
+
受信間隔: - ms
+
最小/平均/最大: - ms
+
サーバー遅延適用: - ms
+
往復遅延(概算): - ms
+
+ + + + +

+ ⚠️ main.go の tickRatemaxRandomDelayMs を変えて試してください +

+ + + + diff --git a/step-07-lag/server/go.mod b/step-07-lag/server/go.mod new file mode 100644 index 0000000..1b23d82 --- /dev/null +++ b/step-07-lag/server/go.mod @@ -0,0 +1,20 @@ +module step07-lag + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-07-lag/server/main.go b/step-07-lag/server/main.go new file mode 100644 index 0000000..885e07f --- /dev/null +++ b/step-07-lag/server/main.go @@ -0,0 +1,288 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。 +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client は接続と書き込みロックをまとめた構造体。 +// +// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは +// 同時に1つだけ」という制約がある。 +// gameLoop の broadcastState と handleWebSocket の ack 送信が +// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。 +type Client struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +// writeText は writeMu を取得してからメッセージを送る。 +func (c *Client) writeText(data []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +// 遅延実験用の設定 +// この値を変えて実験する +const ( + // tick間隔(これを変えてカクつきを観察する) + // 100ms = 普通 + // 500ms = カクカク + tickRate = 100 * time.Millisecond + + // ランダム遅延の最大値(ms) + // 0 = ランダム遅延なし(通常) + // 300 = 最大300msのランダム遅延(ジッター模擬) + maxRandomDelayMs = 0 + + fieldWidth = 800 + fieldHeight = 600 + gridSize = 20 +) + +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type Snake struct { + ID string `json:"id"` + Body []Point `json:"body"` + Direction string `json:"direction"` + Color string `json:"color"` +} + +// GameState にデバッグ情報を追加 +type GameState struct { + Type string `json:"type"` + Snakes map[string]*Snake `json:"snakes"` + TickCount int64 `json:"tickCount"` + ServerTime int64 `json:"serverTime"` // サーバー送信時刻 + DelayApplied int `json:"delayApplied"` // 実際に適用した遅延ms + MyID string `json:"myId,omitempty"` +} + +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +var ( + snakes = make(map[string]*Snake) + clients = make(map[string]*Client) + mu sync.RWMutex + counter int + tickCount int64 +) + +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", +} + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + go gameLoop() + + fmt.Printf("サーバー起動: http://localhost:8080\n") + fmt.Printf("tick間隔: %v\n", tickRate) + fmt.Printf("最大ランダム遅延: %dms\n", maxRandomDelayMs) + fmt.Println("遅延実験: main.go の定数を変えて試してください") + log.Fatal(e.Start(":8080")) +} + +func gameLoop() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + + for { + <-ticker.C + updateGame() + + // ランダム遅延を模擬する(ジッターのシミュレーション) + delay := 0 + if maxRandomDelayMs > 0 { + delay = rand.Intn(maxRandomDelayMs) + time.Sleep(time.Duration(delay) * time.Millisecond) + } + + broadcastState(delay) + } +} + +func updateGame() { + mu.Lock() + defer mu.Unlock() + + tickCount++ + for _, snake := range snakes { + moveSnake(snake) + } +} + +func moveSnake(snake *Snake) { + if len(snake.Body) == 0 { + return + } + + head := snake.Body[0] + var newHead Point + switch snake.Direction { + case "up": + newHead = Point{X: head.X, Y: head.Y - gridSize} + case "down": + newHead = Point{X: head.X, Y: head.Y + gridSize} + case "left": + newHead = Point{X: head.X - gridSize, Y: head.Y} + case "right": + newHead = Point{X: head.X + gridSize, Y: head.Y} + } + + if newHead.X < 0 { newHead.X = fieldWidth - gridSize } + if newHead.X >= fieldWidth { newHead.X = 0 } + if newHead.Y < 0 { newHead.Y = fieldHeight - gridSize } + if newHead.Y >= fieldHeight { newHead.Y = 0 } + + snake.Body = append([]Point{newHead}, snake.Body...) + if len(snake.Body) > 5 { // 固定の長さ(このステップではエサなし) + snake.Body = snake.Body[:5] + } +} + +func broadcastState(delay int) { + mu.RLock() + snakesCopy := make(map[string]*Snake, len(snakes)) + for id, s := range snakes { + sCopy := *s + bodyCopy := make([]Point, len(s.Body)) + copy(bodyCopy, s.Body) + sCopy.Body = bodyCopy + snakesCopy[id] = &sCopy + } + clientsCopy := make(map[string]*Client, len(clients)) + for id, c := range clients { + clientsCopy[id] = c + } + tick := tickCount + mu.RUnlock() + + if len(clientsCopy) == 0 { + return + } + + msg := GameState{ + Type: "state", + Snakes: snakesCopy, + TickCount: tick, + ServerTime: time.Now().UnixMilli(), // 送信時刻をミリ秒で送る + DelayApplied: delay, + } + data, err := json.Marshal(msg) + if err != nil { + log.Printf("broadcastState: json.Marshal エラー: %v", err) + return + } + + for _, client := range clientsCopy { + if err := client.writeText(data); err != nil { + log.Printf("broadcastState: 送信エラー: %v", err) + } + } +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // Client を作成 + client := &Client{conn: conn} + + mu.Lock() + counter++ + snakeID := fmt.Sprintf("snake-%d", counter) + colorIdx := (counter - 1) % len(playerColors) + + body := []Point{} + for i := 0; i < 5; i++ { + body = append(body, Point{ + X: float64((10-i) * gridSize), + Y: float64(((counter - 1) % 10) * 3 * gridSize + gridSize), + }) + } + snakes[snakeID] = &Snake{ + ID: snakeID, + Body: body, + Direction: "right", + Color: playerColors[colorIdx], + } + clients[snakeID] = client + mu.Unlock() + + ack, err := json.Marshal(GameState{ + Type: "state", + MyID: snakeID, + Snakes: map[string]*Snake{}, + }) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := client.writeText(ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + defer func() { + mu.Lock() + delete(snakes, snakeID) + delete(clients, snakeID) + mu.Unlock() + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + + var msg ClientMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + if msg.Type == "input" { + opposites := map[string]string{ + "up": "down", "down": "up", "left": "right", "right": "left", + } + mu.Lock() + if s, ok := snakes[snakeID]; ok { + if opposites[s.Direction] != msg.Direction { + s.Direction = msg.Direction + } + } + mu.Unlock() + } + } + + return nil +} diff --git a/step-08-interpolation/README.md b/step-08-interpolation/README.md new file mode 100644 index 0000000..7dfc58a --- /dev/null +++ b/step-08-interpolation/README.md @@ -0,0 +1,197 @@ +# Step 8: 補間と予測を入れる + +## このステップの目標 + +- クライアント側で補間(interpolation)を実装する +- 補間なし/ありの違いを体感する +- 「サーバーの真実」と「画面上の見え方」を分けて考えられる + +--- + +## 補間とは + +**補間(interpolation)**: 2点間をなめらかに繋ぐこと + +``` +サーバーからの更新が 100ms ごとに来る場合: + +補間なし: + t=0ms: x=100(描画) + t=1〜99ms: x=100のまま(止まって見える) + t=100ms: x=120(瞬間移動) + → カクカクしてワープして見える + +補間あり: + t=0ms: x=100(描画) + t=50ms: x=110(前の位置と次の位置の中間を補間) + t=100ms: x=120(次の位置に到達) + → 滑らかに動いて見える +``` + +--- + +## 線形補間(Linear Interpolation / Lerp) + +``` +lerp(a, b, t) = a + (b - a) * t + a: 開始値 + b: 終了値 + t: 0.0〜1.0(0=a、1=b) + +例: + lerp(100, 120, 0.0) = 100 (位置A) + lerp(100, 120, 0.5) = 110 (中間点) + lerp(100, 120, 1.0) = 120 (位置B) +``` + +--- + +## 実装のアイデア + +### クライアント側の状態 + +```javascript +// サーバーから受け取った「前の位置」と「次の位置」を持つ +const interpolation = { + prevSnapshot: null, // 1つ前のサーバー状態 + nextSnapshot: null, // 最新のサーバー状態 + prevTime: 0, // 前のスナップショットを受け取った時刻 + nextTime: 0, // 次のスナップショットを受け取った時刻 +}; +``` + +### ゲームループ(クライアント側) + +```javascript +// requestAnimationFrame でなめらかに描画 +function gameLoop(timestamp) { + const now = Date.now(); + const elapsed = now - interpolation.prevTime; + const duration = interpolation.nextTime - interpolation.prevTime; + + // 0.0〜1.0 の補間係数を計算 + const t = Math.min(elapsed / duration, 1.0); + + // 各プレイヤーの位置を補間して描画 + drawInterpolated(t); + + requestAnimationFrame(gameLoop); +} +``` + +### 補間して描画 + +```javascript +function getInterpolatedPosition(prevPos, nextPos, t) { + return { + x: lerp(prevPos.x, nextPos.x, t), + y: lerp(prevPos.y, nextPos.y, t), + }; +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} +``` + +--- + +## 「正しいこと」と「気持ちよく見えること」は別 + +**重要なポイント:** + +- サーバーの状態が「正しい真実」 +- クライアントの補間位置は「見せかけの位置」 +- これらは意図的にズレていてよい + +``` +サーバーの真実: x=100 → x=120 (100ms後) +クライアントの表示: x=100 → x=110 → x=120 (50msごとに補間) + +この「嘘」によって画面が滑らかに見える +``` + +これを「見た目の誤魔化し(visual smoothing)」と言います。 +ゲームでは標準的な手法です。 + +--- + +## 補間の限界 + +| 問題 | 内容 | +|---|---| +| 補間の遅れ | 常に「1フレーム前の状態」を補間するので、リアルタイムより遅れる | +| 急な方向転換 | 補間により、実際よりカーブしたように見える | +| 長い遅延 | 遅延が大きすぎると補間しきれなくなる | +| ワープ | 大きすぎる位置変化は補間してもワープに見える | + +--- + +## ファイル構成 + +``` +step-08-interpolation/ +├── README.md +├── server/ +│ ├── main.go (Step 7 と同じ、遅延設定あり) +│ └── go.mod +└── client/ + └── index.html (補間ありの描画) +``` + +--- + +## Step 7との違い(クライアント側) + +Step 7(補間なし): +```javascript +ws.onmessage = (event) => { + state = JSON.parse(event.data); + draw(); // 受け取ったらすぐ描画 +}; +``` + +Step 8(補間あり): +```javascript +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + prevSnapshot = nextSnapshot; // 1つ前の状態を保存 + nextSnapshot = msg; // 新しい状態を保存 + snapshotTimestamp = Date.now(); // 受け取り時刻を記録 + // 描画は requestAnimationFrame に任せる +}; + +function gameLoop() { + const t = calcInterpolationT(); // 補間係数を計算 + drawInterpolated(t); // 補間して描画 + requestAnimationFrame(gameLoop); +} +``` + +--- + +## 練習問題 + +1. **観察:** 補間なし(Step 7)と補間あり(Step 8)の動きを比べる +2. **実験:** `tickRate = 500ms` に設定して、補間の効果を確認する +3. **応用:** 補間係数 `t` を `0.0〜1.0` 以外にしたらどうなるか試す +4. **考察:** 「補間できない問題」を1つ考えて説明する + +--- + +## 理解確認クイズ + +1. `lerp(10, 20, 0.3)` の結果は何ですか? +2. 補間は「正確さ」と「滑らかさ」のどちらを優先していますか? +3. なぜ `requestAnimationFrame` を使うのですか? +4. 補間があると、表示上は常にサーバー状態より少し「遅れて」いますか? +5. 補間だけではワープを完全に防げない理由は何ですか? + +--- + +## 次のステップへ + +Step 8で「見た目を滑らかにする」ための基礎を学びました。 +Step 9では、1つの部屋から10人部屋への拡張を学びます。 + +「1つのグローバルなゲーム空間」から「複数の部屋」に分ける設計です。 diff --git a/step-08-interpolation/client/index.html b/step-08-interpolation/client/index.html new file mode 100644 index 0000000..da0c259 --- /dev/null +++ b/step-08-interpolation/client/index.html @@ -0,0 +1,251 @@ + + + + + Step 8: 補間 + + + +

Step 8: 補間(Interpolation)

+ +
+ + + 切断中 +
+ +
+ Tick: 0 + 自分のID: なし + 補間係数(t): 0.00 +
+ + +
+
+

❌ 補間なし(受け取ったらすぐ描画)

+ +
+
+

✅ 補間あり(前後の位置をなめらかに補間)

+ +
+
+ +

+ 矢印キーで移動 / サーバーの tickRate を遅く(500ms など)設定すると補間の効果が分かりやすい +

+ + + + diff --git a/step-08-interpolation/server/go.mod b/step-08-interpolation/server/go.mod new file mode 100644 index 0000000..7edb1f9 --- /dev/null +++ b/step-08-interpolation/server/go.mod @@ -0,0 +1,20 @@ +module step08-interpolation + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-08-interpolation/server/main.go b/step-08-interpolation/server/main.go new file mode 100644 index 0000000..0fdbf52 --- /dev/null +++ b/step-08-interpolation/server/main.go @@ -0,0 +1,255 @@ +package main + +// Step 8 のサーバーは Step 7 と同じ構成で、tickRate と maxRandomDelayMs を調整して使う +// クライアント側の補間の実験に使うため、遅延設定は残してある + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// ⚠️ 開発用設定: 全オリジンを許可。本番では許可オリジンを限定すること。 +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client は接続と書き込みロックをまとめた構造体。 +// +// gorilla/websocket は「同一コネクションへの WriteMessage 系呼び出しは +// 同時に1つだけ」という制約がある。 +// gameLoop の broadcastState と handleWebSocket の ack 送信が +// 同一 conn に並行して書き込む可能性があるため、writeMu で直列化する。 +type Client struct { + conn *websocket.Conn + writeMu sync.Mutex +} + +// writeText は writeMu を取得してからメッセージを送る。 +func (c *Client) writeText(data []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +// 補間実験用の設定 +// 意図的に遅い tickRate でも補間で滑らかに見えることを確認する +const ( + tickRate = 100 * time.Millisecond // 変えて実験してみてください + maxRandomDelayMs = 0 // ランダム遅延(0=なし) + fieldWidth = 800 + fieldHeight = 600 + gridSize = 20 +) + +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type Snake struct { + ID string `json:"id"` + Body []Point `json:"body"` + Direction string `json:"direction"` + Color string `json:"color"` +} + +type GameState struct { + Type string `json:"type"` + Snakes map[string]*Snake `json:"snakes"` + TickCount int64 `json:"tickCount"` + ServerTime int64 `json:"serverTime"` // クライアントの補間計算に使う + MyID string `json:"myId,omitempty"` +} + +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +var ( + snakes = make(map[string]*Snake) + clients = make(map[string]*Client) + mu sync.RWMutex + counter int + tick int64 +) + +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", +} + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + go gameLoop() + + fmt.Printf("サーバー起動: http://localhost:8080\n") + fmt.Printf("tick間隔: %v(補間実験用)\n", tickRate) + log.Fatal(e.Start(":8080")) +} + +func gameLoop() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + + for { + <-ticker.C + updateGame() + + if maxRandomDelayMs > 0 { + time.Sleep(time.Duration(rand.Intn(maxRandomDelayMs)) * time.Millisecond) + } + + broadcastState() + } +} + +func updateGame() { + mu.Lock() + defer mu.Unlock() + + tick++ + for _, snake := range snakes { + moveSnake(snake) + } +} + +func moveSnake(snake *Snake) { + if len(snake.Body) == 0 { + return + } + head := snake.Body[0] + var newHead Point + switch snake.Direction { + case "up": + newHead = Point{X: head.X, Y: head.Y - gridSize} + case "down": + newHead = Point{X: head.X, Y: head.Y + gridSize} + case "left": + newHead = Point{X: head.X - gridSize, Y: head.Y} + case "right": + newHead = Point{X: head.X + gridSize, Y: head.Y} + } + if newHead.X < 0 { newHead.X = fieldWidth - gridSize } + if newHead.X >= fieldWidth { newHead.X = 0 } + if newHead.Y < 0 { newHead.Y = fieldHeight - gridSize } + if newHead.Y >= fieldHeight { newHead.Y = 0 } + + snake.Body = append([]Point{newHead}, snake.Body...) + if len(snake.Body) > 5 { + snake.Body = snake.Body[:5] + } +} + +func broadcastState() { + mu.RLock() + snakesCopy := make(map[string]*Snake, len(snakes)) + for id, s := range snakes { + sc := *s + bc := make([]Point, len(s.Body)) + copy(bc, s.Body) + sc.Body = bc + snakesCopy[id] = &sc + } + clientsCopy := make(map[string]*Client, len(clients)) + for id, c := range clients { + clientsCopy[id] = c + } + t := tick + mu.RUnlock() + + if len(clientsCopy) == 0 { + return + } + + msg := GameState{ + Type: "state", + Snakes: snakesCopy, + TickCount: t, + ServerTime: time.Now().UnixMilli(), + } + data, err := json.Marshal(msg) + if err != nil { + log.Printf("broadcastState: json.Marshal エラー: %v", err) + return + } + for _, client := range clientsCopy { + if err := client.writeText(data); err != nil { + log.Printf("broadcastState: 送信エラー: %v", err) + } + } +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // Client を作成 + client := &Client{conn: conn} + + mu.Lock() + counter++ + id := fmt.Sprintf("snake-%d", counter) + color := playerColors[(counter-1)%len(playerColors)] + body := []Point{} + for i := 0; i < 5; i++ { + body = append(body, Point{ + X: float64((10-i) * gridSize), + Y: float64(((counter-1)%10)*3*gridSize + gridSize), + }) + } + snakes[id] = &Snake{ID: id, Body: body, Direction: "right", Color: color} + clients[id] = client + mu.Unlock() + + ack, err := json.Marshal(GameState{Type: "state", MyID: id, Snakes: map[string]*Snake{}}) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := client.writeText(ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + defer func() { + mu.Lock() + delete(snakes, id) + delete(clients, id) + mu.Unlock() + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + var msg ClientMessage + if json.Unmarshal(raw, &msg) != nil { + continue + } + if msg.Type == "input" { + opposites := map[string]string{"up": "down", "down": "up", "left": "right", "right": "left"} + mu.Lock() + if s, ok := snakes[id]; ok && opposites[s.Direction] != msg.Direction { + s.Direction = msg.Direction + } + mu.Unlock() + } + } + return nil +} diff --git a/step-09-rooms/README.md b/step-09-rooms/README.md new file mode 100644 index 0000000..dcaf967 --- /dev/null +++ b/step-09-rooms/README.md @@ -0,0 +1,176 @@ +# Step 9: 10人部屋を作る + +## このステップの目標 + +- 部屋(Room)単位でゲームを管理できる +- 新規接続を適切な部屋に振り分けられる +- 部屋ごとにゲームループを持てる + +--- + +## なぜグローバル1マップではダメなのか + +Step 6 までは全員が同じ1つのフィールドにいました。 + +問題点: +``` +1. スケールしない + → 100人が1フィールドにいると、毎tick100人分のデータを全員に送る + → 1人が受け取るデータ量が100人分になる + +2. ゲームバランスが崩れる + → 人数が増えるほどフィールドが混雑する + → ゲームデザインが成立しない + +3. プライバシー(遊びの隔離) + → 知らない人と同じフィールドに強制されたくない場合も +``` + +**10人部屋に分ければ:** +``` +→ 各部屋は最大10人のゲーム空間 +→ 部屋内の通信は部屋内だけで閉じる +→ サーバーが複数部屋を管理できる +``` + +--- + +## Room の設計 + +```go +type Room struct { + ID string + Snakes map[string]*Snake + Clients map[string]*websocket.Conn + Foods []Food + mu sync.RWMutex + stopCh chan struct{} // ゲームループを止めるためのチャンネル +} + +const MaxPlayersPerRoom = 10 +``` + +--- + +## 部屋の振り分けロジック + +``` +新しいプレイヤーが接続した + ↓ +空き部屋(10人未満の部屋)はあるか? + ↓ ある ↓ ない + その部屋に入る 新しい部屋を作る +``` + +```go +func findOrCreateRoom() *Room { + for _, room := range rooms { + if len(room.Snakes) < MaxPlayersPerRoom { + return room + } + } + // 満室なら新しい部屋を作る + return createRoom() +} +``` + +--- + +## 部屋ごとのゲームループ + +各部屋が自分のゲームループを持ちます。 + +```go +func (r *Room) Start() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + r.update() + r.broadcast() + case <-r.stopCh: + return // 部屋が空になったらループを止める + } + } +} +``` + +--- + +## データの流れ + +``` +接続 → findOrCreateRoom() → Room に追加 → Room のゲームループが動く + ↓ +入力受付ハンドラ → Room の Snake の方向を更新 + ↓ +Room のゲームループが移動 → Room 内の全員にブロードキャスト +``` + +--- + +## ファイル構成 + +``` +step-09-rooms/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## よくある設計ミス + +### 1. 部屋のゲームループを複数起動してしまう +```go +// ❌ 接続のたびに goroutine が増える +func handleWebSocket(c echo.Context) error { + room := findOrCreateRoom() + go room.Start() // ← 2回目の接続で2つ目のループが起動する +``` +対策:`room.running` フラグで管理し、起動済みなら起動しない + +### 2. 部屋のロックを取りすぎる +```go +// ❌ 外側のロックと内側のロックが絡み合うとデッドロックになる +globalMu.Lock() +room.mu.Lock() // ← ロックの順番が違うと危険 +``` + +### 3. 空になった部屋を削除しない +全員が退出した部屋は削除しないと、メモリが増え続ける + +--- + +## 練習問題 + +1. **基本:** 11人目が接続したとき、自動で2つ目の部屋が作られることを確認する +2. **応用:** 部屋一覧をクライアントに表示する(部屋ID、現在人数) +3. **応用:** 空になった部屋を自動で削除する +4. **発展:** プレイヤーが部屋番号を指定して入れるようにする + +--- + +## 理解確認クイズ + +1. なぜグローバル1マップではスケールしないのですか? +2. 部屋が「満室(10人)」になったらどうなりますか? +3. 各部屋が自分のゲームループを持つ理由は何ですか? +4. `stopCh chan struct{}` は何に使いますか? +5. 部屋Aにいるプレイヤーが部屋Bのプレイヤーの状態を受け取らないようにするにはどうしますか? + +--- + +## 次のステップへ + +Step 9で複数の部屋を管理できるようになりました。 +Step 10では、現実のネットワーク問題——切断・再接続——への対応を学びます。 + +「切断処理を書いていない実装は未完成」 +次はこれを解決します。 diff --git a/step-09-rooms/client/index.html b/step-09-rooms/client/index.html new file mode 100644 index 0000000..56d07e4 --- /dev/null +++ b/step-09-rooms/client/index.html @@ -0,0 +1,192 @@ + + + + + Step 9: 10人部屋 + + + +

Step 9: 10人部屋

+ +
+ + +
+ + +
+ 部屋ID: なし / + 人数: 0 / 10人 +
+ +
+ 切断中 + 自分: なし + スコア: 0 +
+ + +

+ 10人以上接続すると自動で2つ目の部屋が作られます
+ 矢印キーで移動 / 同じ部屋のプレイヤーだけが表示されます +

+ + + + diff --git a/step-09-rooms/server/go.mod b/step-09-rooms/server/go.mod new file mode 100644 index 0000000..9207e2a --- /dev/null +++ b/step-09-rooms/server/go.mod @@ -0,0 +1,20 @@ +module step09-rooms + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-09-rooms/server/main.go b/step-09-rooms/server/main.go new file mode 100644 index 0000000..55d5458 --- /dev/null +++ b/step-09-rooms/server/main.go @@ -0,0 +1,370 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +const ( + MaxPlayersPerRoom = 10 + tickRate = 150 * time.Millisecond + fieldWidth = 800 + fieldHeight = 600 + gridSize = 20 + initialSnakeLen = 5 + foodCount = 5 +) + +// ----- データ構造 ----- + +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type Snake struct { + ID string `json:"id"` + Body []Point `json:"body"` + Direction string `json:"direction"` + Color string `json:"color"` + Score int `json:"score"` +} + +type Food struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +// RoomInfo はクライアントに送る部屋情報 +type RoomInfo struct { + RoomID string `json:"roomId"` + Count int `json:"count"` +} + +type GameState struct { + Type string `json:"type"` + Snakes map[string]*Snake `json:"snakes"` + Foods []Food `json:"foods"` + MyID string `json:"myId,omitempty"` + RoomID string `json:"roomId"` + Count int `json:"count"` +} + +// ----- Room ----- + +type Room struct { + ID string + Snakes map[string]*Snake + Clients map[string]*websocket.Conn + Foods []Food + mu sync.RWMutex + stopCh chan struct{} + running bool +} + +func newRoom(id string) *Room { + r := &Room{ + ID: id, + Snakes: make(map[string]*Snake), + Clients: make(map[string]*websocket.Conn), + stopCh: make(chan struct{}), + } + // エサを初期配置 + for i := 0; i < foodCount; i++ { + r.Foods = append(r.Foods, spawnFood()) + } + return r +} + +func spawnFood() Food { + return Food{ + X: float64(rand.Intn(fieldWidth/gridSize) * gridSize), + Y: float64(rand.Intn(fieldHeight/gridSize) * gridSize), + } +} + +// Start はこの部屋のゲームループを起動する(1部屋1回だけ呼ぶ) +func (r *Room) Start() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + + log.Printf("部屋 %s: ゲームループ開始", r.ID) + + for { + select { + case <-ticker.C: + r.update() + r.broadcastState() + case <-r.stopCh: + log.Printf("部屋 %s: ゲームループ停止", r.ID) + return + } + } +} + +func (r *Room) update() { + r.mu.Lock() + defer r.mu.Unlock() + + for _, snake := range r.Snakes { + r.moveSnake(snake) + } +} + +func (r *Room) moveSnake(snake *Snake) { + if len(snake.Body) == 0 { + return + } + head := snake.Body[0] + var newHead Point + switch snake.Direction { + case "up": + newHead = Point{X: head.X, Y: head.Y - gridSize} + case "down": + newHead = Point{X: head.X, Y: head.Y + gridSize} + case "left": + newHead = Point{X: head.X - gridSize, Y: head.Y} + case "right": + newHead = Point{X: head.X + gridSize, Y: head.Y} + } + if newHead.X < 0 { newHead.X = fieldWidth - gridSize } + if newHead.X >= fieldWidth { newHead.X = 0 } + if newHead.Y < 0 { newHead.Y = fieldHeight - gridSize } + if newHead.Y >= fieldHeight { newHead.Y = 0 } + + snake.Body = append([]Point{newHead}, snake.Body...) + + ate := false + for i, food := range r.Foods { + if food.X == newHead.X && food.Y == newHead.Y { + snake.Score++ + ate = true + r.Foods[i] = spawnFood() + break + } + } + // エサを食べていなければ尻尾を削除して長さを維持する + // 食べたときは削除しないことで長さが1増える(成長) + if !ate { + snake.Body = snake.Body[:len(snake.Body)-1] + } +} + +func (r *Room) broadcastState() { + r.mu.RLock() + snakesCopy := make(map[string]*Snake, len(r.Snakes)) + for id, s := range r.Snakes { + sc := *s + bc := make([]Point, len(s.Body)) + copy(bc, s.Body) + sc.Body = bc + snakesCopy[id] = &sc + } + foodsCopy := make([]Food, len(r.Foods)) + copy(foodsCopy, r.Foods) + clientsCopy := make(map[string]*websocket.Conn, len(r.Clients)) + for id, conn := range r.Clients { + clientsCopy[id] = conn + } + count := len(r.Snakes) + roomID := r.ID + r.mu.RUnlock() + + if len(clientsCopy) == 0 { + return + } + + msg := GameState{ + Type: "state", + Snakes: snakesCopy, + Foods: foodsCopy, + RoomID: roomID, + Count: count, + } + data, _ := json.Marshal(msg) + for _, conn := range clientsCopy { + conn.WriteMessage(websocket.TextMessage, data) + } +} + +func (r *Room) addPlayer(id string, conn *websocket.Conn, snake *Snake) { + r.mu.Lock() + r.Snakes[id] = snake + r.Clients[id] = conn + r.mu.Unlock() +} + +func (r *Room) removePlayer(id string) bool { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.Snakes, id) + delete(r.Clients, id) + return len(r.Snakes) == 0 +} + +func (r *Room) PlayerCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.Snakes) +} + +// ----- グローバル管理 ----- + +var ( + rooms = make(map[string]*Room) + globalMu sync.Mutex + roomCounter int + snakeCounter int +) + +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", + "#a29bfe", "#fd79a8", +} + +// findOrCreateRoom は空き部屋を探す、なければ作る +func findOrCreateRoom() *Room { + globalMu.Lock() + defer globalMu.Unlock() + + for _, room := range rooms { + if room.PlayerCount() < MaxPlayersPerRoom { + return room + } + } + + // 新しい部屋を作る + roomCounter++ + roomID := fmt.Sprintf("room-%d", roomCounter) + room := newRoom(roomID) + rooms[roomID] = room + + // 部屋のゲームループを起動(1回だけ) + room.running = true + go room.Start() + + log.Printf("新しい部屋を作成: %s", roomID) + return room +} + +// deleteRoomIfEmpty は部屋が空なら削除する +func deleteRoomIfEmpty(roomID string) { + globalMu.Lock() + defer globalMu.Unlock() + + if room, ok := rooms[roomID]; ok { + if room.PlayerCount() == 0 { + close(room.stopCh) // ゲームループを止める + delete(rooms, roomID) + log.Printf("空になった部屋を削除: %s", roomID) + } + } +} + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + fmt.Println("サーバー起動: http://localhost:8080") + fmt.Printf("1部屋最大: %d人\n", MaxPlayersPerRoom) + log.Fatal(e.Start(":8080")) +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // 空き部屋を探すか新規作成 + room := findOrCreateRoom() + + // プレイヤーを作成 + globalMu.Lock() + snakeCounter++ + snakeID := fmt.Sprintf("snake-%d", snakeCounter) + colorIdx := (snakeCounter - 1) % len(playerColors) + globalMu.Unlock() + + body := []Point{} + for i := 0; i < initialSnakeLen; i++ { + body = append(body, Point{ + X: float64((10-i) * gridSize), + Y: float64(rand.Intn(fieldHeight/gridSize-1)*gridSize + gridSize), + }) + } + snake := &Snake{ + ID: snakeID, + Body: body, + Direction: "right", + Color: playerColors[colorIdx], + } + + room.addPlayer(snakeID, conn, snake) + log.Printf("プレイヤー %s が部屋 %s に入室(現在%d人)", snakeID, room.ID, room.PlayerCount()) + + // 自分のID と 部屋IDを通知 + ack, _ := json.Marshal(GameState{ + Type: "state", + MyID: snakeID, + RoomID: room.ID, + Snakes: map[string]*Snake{}, + Foods: []Food{}, + }) + conn.WriteMessage(websocket.TextMessage, ack) + + // 切断時の処理 + defer func() { + empty := room.removePlayer(snakeID) + log.Printf("プレイヤー %s が部屋 %s を退室", snakeID, room.ID) + if empty { + deleteRoomIfEmpty(room.ID) + } + }() + + // 入力受付ループ + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + var msg ClientMessage + if json.Unmarshal(raw, &msg) != nil { + continue + } + if msg.Type == "input" { + opposites := map[string]string{ + "up": "down", "down": "up", "left": "right", "right": "left", + } + room.mu.Lock() + if s, ok := room.Snakes[snakeID]; ok && opposites[s.Direction] != msg.Direction { + s.Direction = msg.Direction + } + room.mu.Unlock() + } + } + return nil +} diff --git a/step-10-reconnect/README.md b/step-10-reconnect/README.md new file mode 100644 index 0000000..246aa2d --- /dev/null +++ b/step-10-reconnect/README.md @@ -0,0 +1,163 @@ +# Step 10: 切断・再接続に対応する + +## このステップの目標 + +- heartbeat(ping/pong)で生きている接続を確認する +- 切断を確実に検知してゴーストプレイヤーを防ぐ +- 再接続の設計パターンを理解する + +--- + +## 「切断処理を書いていない実装は未完成」 + +実際のネットワーク環境では、接続が突然切れることは日常的です。 + +``` +切断のパターン: +1. ユーザーがブラウザを閉じる(close イベントが届く) +2. PCがスリープした(数秒後に切断を検知) +3. ネットワーク障害(タイムアウトまで検知できない場合がある) +4. 中間ルーターがアイドル接続を切断する(無音接続の切断) +``` + +特に **3と4** が問題です。TCP の接続が「論理的には切れているのに、サーバーが気づかない」状態になります。 + +--- + +## ゴーストプレイヤー問題 + +``` +何が起きるか: +1. プレイヤーがネットワーク障害で切断する +2. サーバーは気づかない(close イベントが来ない) +3. そのプレイヤーは「接続中」のままサーバーに残り続ける +4. 他のプレイヤーには「ゴースト(幽霊)」として見え続ける +5. ゲームが正常に動作しなくなる + +解決策: Heartbeat(ping/pong) +→ 定期的に「生きてますか?」と確認する +→ 返事がなければ「切断済み」として扱う +``` + +--- + +## Heartbeat の仕組み + +``` +サーバー → クライアント: ping(「生きてますか?」) +クライアント → サーバー: pong(「生きてます」) + +設定例: + ping送信間隔: 30秒 + pong待ちタイムアウト: 10秒(この間に返事が来なければ切断扱い) +``` + +WebSocket プロトコルには ping/pong フレームが組み込まれています。 +gorilla/websocket でも `SetPingHandler` / `SetPongHandler` で扱えます。 + +--- + +## gorilla/websocket の ping/pong + +```go +// サーバー側 +conn.SetPongHandler(func(string) error { + // pong を受け取ったら deadline を延長する + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil +}) + +// 定期的に ping を送る +go func() { + ticker := time.NewTicker(pingInterval) + for { + <-ticker.C + conn.WriteMessage(websocket.PingMessage, nil) + } +}() + +// read deadline を設定(これを超えると ReadMessage がエラーを返す) +conn.SetReadDeadline(time.Now().Add(pongWait)) +``` + +--- + +## close / error / timeout の違い + +| 種類 | 発生原因 | gorilla での挙動 | +|---|---|---| +| close | 正常な切断(`ws.close()`) | `ReadMessage` が `*websocket.CloseError` を返す | +| error | 接続エラー | `ReadMessage` がエラーを返す | +| read deadline | timeout(応答なし) | `ReadMessage` がタイムアウトエラーを返す | + +全て `ReadMessage` がエラーを返すことで検知できます。 +つまり、「`ReadMessage` がエラーを返したらプレイヤーを削除」という処理でOKです。 + +--- + +## 再接続の設計パターン + +### パターン1: 切断したら別のプレイヤーとして再入場 +最もシンプルです。 +- 切断 → サーバーからプレイヤーを削除 +- 再接続 → 新しいプレイヤーとして扱う +- 進行途中のスコアや位置は失われる + +### パターン2: セッションIDで再接続を識別する +少し複雑ですが、状態を引き継げます。 +- 最初の接続時にセッションIDを発行 +- クライアントが localStorage にセッションIDを保存 +- 再接続時にセッションIDを送ると、前の状態を復元 + +### クライアント側の再接続 + +```javascript +function connect() { + ws = new WebSocket('ws://localhost:8080/ws'); + + ws.onclose = () => { + // 一定時間後に再接続を試みる + setTimeout(connect, 3000); + }; +} +``` + +--- + +## ファイル構成 + +``` +step-10-reconnect/ +├── README.md +├── server/ +│ ├── main.go +│ └── go.mod +└── client/ + └── index.html +``` + +--- + +## 練習問題 + +1. **観察:** タブを閉じてサーバーのログを見る。切断検知のタイミングを確認する +2. **実験:** デバイスのWiFiをオフにしてからオンにする。タイムアウトまでの時間を計る +3. **応用:** クライアントに自動再接続を実装する(3秒後に再接続) +4. **発展:** セッションIDを使った再接続で、スコアを引き継ぐ処理を実装する + +--- + +## 理解確認クイズ + +1. ゴーストプレイヤーとは何ですか? +2. heartbeat がないと何が問題になりますか? +3. ping/pong の役割をそれぞれ説明してください +4. `SetReadDeadline` を使う理由は何ですか? +5. 再接続後に状態を引き継ぐにはどうすればいいですか? + +--- + +## 次のステップへ + +Step 10で現実的なネットワーク問題に対応できるようになりました。 +Step 11では、「動く」から「効率よく動く」へ。通信量の最適化を学びます。 diff --git a/step-10-reconnect/client/index.html b/step-10-reconnect/client/index.html new file mode 100644 index 0000000..63e6cfc --- /dev/null +++ b/step-10-reconnect/client/index.html @@ -0,0 +1,206 @@ + + + + + Step 10: 切断・再接続 + + + +

Step 10: 切断・再接続

+ +
+ + + +
+ +
切断中
+
+ 自分のID: なし / + 再接続回数: 0 +
+ + + +
+

+ 「自動再接続 ON」をクリックしてから切断ボタン or WiFiをオフにして挙動を確認
+ サーバーの ping/pong 設定を変えてタイムアウトを体験できます +

+ + + + diff --git a/step-10-reconnect/server/go.mod b/step-10-reconnect/server/go.mod new file mode 100644 index 0000000..2efdc1e --- /dev/null +++ b/step-10-reconnect/server/go.mod @@ -0,0 +1,20 @@ +module step10-reconnect + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-10-reconnect/server/main.go b/step-10-reconnect/server/main.go new file mode 100644 index 0000000..d7a5c9e --- /dev/null +++ b/step-10-reconnect/server/main.go @@ -0,0 +1,329 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +// ⚠️ 開発用設定: CheckOrigin で全オリジンを許可している。 +// これは Cross-Site WebSocket Hijacking(CSWSH)に対して無防備になるため +// 本番環境では絶対に使用しないこと。 +// +// 本番向けの最低限の対策例: +// CheckOrigin: func(r *http.Request) bool { +// origin := r.Header.Get("Origin") +// return origin == "https://yourdomain.example.com" +// } +// さらに認証が必要な場合は JWT や Cookie セッションをアップグレード前に検証する。 +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, // 開発専用: 全オリジン許可 +} + +// heartbeat の設定 +const ( + pingInterval = 10 * time.Second // この間隔で ping を送る + pongWait = 15 * time.Second // この時間内に pong が来なければ切断扱い + + tickRate = 150 * time.Millisecond + fieldWidth = 800 + fieldHeight = 600 + gridSize = 20 +) + +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type Snake struct { + ID string `json:"id"` + Body []Point `json:"body"` + Direction string `json:"direction"` + Color string `json:"color"` +} + +type GameState struct { + Type string `json:"type"` + Snakes map[string]*Snake `json:"snakes"` + MyID string `json:"myId,omitempty"` +} + +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +// Client は接続と書き込みロックをまとめた構造体。 +// +// gorilla/websocket の仕様: +// 「同一コネクションへの WriteMessage 系呼び出しは同時に1つしか許可されない」 +// +// broadcastState(gameLoop goroutine)と ping 送信 goroutine が +// 同じ conn に並行して書き込む可能性があるため、 +// WriteMessage はすべて writeMu で直列化する。 +// ping には WriteControl を使う(gorilla ドキュメントに +// "Close and WriteControl can be called concurrently with all other methods" +// と明記されているため writeMu 不要)。 +type Client struct { + conn *websocket.Conn + writeMu sync.Mutex // WriteMessage 呼び出しを直列化するロック +} + +// writeText は writeMu を取得してから TextMessage を送る。 +// エラーを返すので呼び出し側でハンドリングする。 +func (c *Client) writeText(data []byte) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +var ( + snakes = make(map[string]*Snake) + clients = make(map[string]*Client) + mu sync.RWMutex + counter int +) + +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", +} + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + go gameLoop() + + fmt.Println("サーバー起動: http://localhost:8080") + fmt.Printf("ping間隔: %v / pong待ち: %v\n", pingInterval, pongWait) + log.Fatal(e.Start(":8080")) +} + +func gameLoop() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + for { + <-ticker.C + updateGame() + broadcastState() + } +} + +func updateGame() { + mu.Lock() + defer mu.Unlock() + for _, s := range snakes { + moveSnake(s) + } +} + +func moveSnake(s *Snake) { + if len(s.Body) == 0 { + return + } + head := s.Body[0] + var nh Point + switch s.Direction { + case "up": + nh = Point{head.X, head.Y - gridSize} + case "down": + nh = Point{head.X, head.Y + gridSize} + case "left": + nh = Point{head.X - gridSize, head.Y} + case "right": + nh = Point{head.X + gridSize, head.Y} + } + if nh.X < 0 { + nh.X = fieldWidth - gridSize + } + if nh.X >= fieldWidth { + nh.X = 0 + } + if nh.Y < 0 { + nh.Y = fieldHeight - gridSize + } + if nh.Y >= fieldHeight { + nh.Y = 0 + } + s.Body = append([]Point{nh}, s.Body...) + if len(s.Body) > 5 { + s.Body = s.Body[:5] + } +} + +func broadcastState() { + mu.RLock() + sc := make(map[string]*Snake, len(snakes)) + for id, s := range snakes { + cp := *s + bc := make([]Point, len(s.Body)) + copy(bc, s.Body) + cp.Body = bc + sc[id] = &cp + } + cc := make(map[string]*Client, len(clients)) + for id, c := range clients { + cc[id] = c + } + mu.RUnlock() + + if len(cc) == 0 { + return + } + + data, err := json.Marshal(GameState{Type: "state", Snakes: sc}) + if err != nil { + log.Printf("broadcastState: json.Marshal エラー: %v", err) + return + } + + // 送信エラーが起きた接続を後でまとめて削除するためのリスト + var failed []string + for id, client := range cc { + if err := client.writeText(data); err != nil { + log.Printf("broadcastState: 送信エラー(切断扱い): %s: %v", id, err) + failed = append(failed, id) + } + } + + // 送信失敗した接続を削除する + if len(failed) > 0 { + mu.Lock() + for _, id := range failed { + if c, ok := clients[id]; ok { + c.conn.Close() + } + delete(clients, id) + delete(snakes, id) + } + mu.Unlock() + } +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + // --- Heartbeat の設定 --- + + // 最初の read deadline を設定。 + // この時間内に pong(またはメッセージ)が来なければ ReadMessage がエラーを返す。 + conn.SetReadDeadline(time.Now().Add(pongWait)) + + // pong を受け取ったら deadline を延長する + conn.SetPongHandler(func(appData string) error { + log.Printf("pong 受信") + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + // プレイヤー登録 + mu.Lock() + counter++ + id := fmt.Sprintf("snake-%d", counter) + color := playerColors[(counter-1)%len(playerColors)] + body := []Point{} + for i := 0; i < 5; i++ { + body = append(body, Point{ + float64((10 - i) * gridSize), + float64((counter-1)%10*3*gridSize + gridSize), + }) + } + client := &Client{conn: conn} + snakes[id] = &Snake{ID: id, Body: body, Direction: "right", Color: color} + clients[id] = client + mu.Unlock() + + log.Printf("プレイヤー接続: %s", id) + + // 初回 ack を送信(writeMu で直列化) + ack, err := json.Marshal(GameState{Type: "state", MyID: id, Snakes: map[string]*Snake{}}) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := client.writeText(ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + // --- ping を定期送信する goroutine --- + // WriteControl は WriteMessage と同時に呼んでも安全(gorilla の仕様より)。 + // writeMu を取得する必要はなく、WriteControl のみを使う。 + pingStop := make(chan struct{}) + go func() { + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + // WriteControl は deadline を引数に取る + deadline := time.Now().Add(5 * time.Second) + if err := conn.WriteControl(websocket.PingMessage, nil, deadline); err != nil { + log.Printf("ping 送信エラー: %v", err) + return + } + log.Printf("ping 送信 → %s", id) + case <-pingStop: + return + } + } + }() + + defer func() { + close(pingStop) // ping goroutine を止める + + mu.Lock() + delete(snakes, id) + delete(clients, id) + mu.Unlock() + log.Printf("プレイヤー切断: %s", id) + }() + + // メッセージ受信ループ + // read deadline を超えると ReadMessage がエラーを返す → 切断扱いになる + for { + _, raw, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("異常切断: %s: %v", id, err) + } else { + log.Printf("切断: %s: %v", id, err) + } + break + } + + // メッセージを受け取ったら read deadline をリセット + conn.SetReadDeadline(time.Now().Add(pongWait)) + + var msg ClientMessage + if json.Unmarshal(raw, &msg) != nil { + continue + } + + if msg.Type == "input" { + opposites := map[string]string{ + "up": "down", "down": "up", "left": "right", "right": "left", + } + mu.Lock() + if s, ok := snakes[id]; ok && opposites[s.Direction] != msg.Direction { + s.Direction = msg.Direction + } + mu.Unlock() + } + } + + return nil +} diff --git a/step-11-optimization/README.md b/step-11-optimization/README.md new file mode 100644 index 0000000..d23a8c3 --- /dev/null +++ b/step-11-optimization/README.md @@ -0,0 +1,197 @@ +# Step 11: 最適化を考える + +## このステップの目標 + +- 全量送信の限界を理解する +- 差分送信の発想を持つ +- JSON のサイズと送信頻度を観測する + +--- + +## 「動く」から「無駄が多い」に気づく + +Step 6 の蛇ゲームをそのまま10人でプレイすると: + +``` +毎 tick(100ms ごと)に送るデータ: + +蛇1匹 = body 10マス × (x, y) × 8バイト ≒ 160バイト +10人 = 1600バイト +エサ × 5 = 100バイト +JSONオーバーヘッド(フィールド名など) = 200〜500バイト + +合計: ≒ 2000〜2500バイト / tick / 1クライアント + ×10クライアント + ×10 tick/秒 + ≒ 200〜250KB/秒 + +これが100人になると... + 2500KB/秒 = 2.5MB/秒 +``` + +蛇が長くなれば体のデータも増える。10人でも油断できない。 + +--- + +## 全量送信の問題 + +``` +毎 tick、変化していないデータも全部送っている + +例:10人中2人しか動いていない tick でも、 + 10人分の全データを全員に送っている + +→ 無駄なデータが大量に流れる +``` + +--- + +## 差分送信(Delta Compression) + +**変化した部分だけを送る。** + +```go +// 全量送信の例 +type GameState struct { + Snakes map[string]*Snake // 全員のデータ + Foods []Food // 全エサ +} + +// 差分送信の例 +type DeltaState struct { + // 動いた蛇だけ + UpdatedSnakes map[string]*Snake `json:"updated,omitempty"` + // 死んだ蛇のID(削除) + RemovedSnakes []string `json:"removed,omitempty"` + // エサの変化(食べられたもの/新しく生えたもの) + UpdatedFoods []FoodDelta `json:"foods,omitempty"` +} +``` + +--- + +## JSONのサイズ観測方法 + +### サーバー側でサイズをログに出す + +```go +data, _ := json.Marshal(msg) +log.Printf("送信サイズ: %d bytes", len(data)) +``` + +### クライアント側でサイズを表示する + +```javascript +ws.onmessage = (event) => { + const size = new Blob([event.data]).size; + console.log(`受信サイズ: ${size} bytes`); +}; +``` + +--- + +## 最適化の方向性 + +### 1. 差分送信(最も効果が高い) +変化した部分だけを送る。実装が複雑になるが効果は大きい。 + +### 2. 送信頻度の調整 +重要度に応じて更新頻度を変える。 +- プレイヤーの位置: 100ms +- スコアなどの状態: 1秒 +- 設定・ルームInfo: 変化時のみ + +### 3. JSONより軽量な形式を使う +- **MessagePack**: JSONの2〜5割サイズ削減 +- **プロトコルバッファ(protobuf)**: 更にコンパクト +- **バイナリプロトコル**: 手作りで最小化(上級) + +### 4. 送信対象を絞る +視野内のデータだけ送る(大きなマップのゲームで効果的) + +--- + +## ファイル構成 + +``` +step-11-optimization/ +├── README.md +├── server/ +│ ├── main.go (サイズ計測を追加した版) +│ └── go.mod +└── client/ + └── index.html (サイズ計測パネル付き) +``` + +--- + +## 実験内容 + +### 実験1: JSONサイズの計測 +- プレイヤー数を増やしてサイズの変化を観察する +- tick間隔を変えて1秒あたりの転送量を計算する + +### 実験2: 更新頻度の変化 +- tick間隔を 33ms(30FPS)→ 100ms(10FPS)→ 500ms に変えて観察する + +### 実験3: フィールド名の省略 +```json +// 通常のJSON +{"id":"snake-1","x":100,"y":200,"direction":"up"} + +// フィールド名を短縮 +{"i":"snake-1","x":100,"y":200,"d":"up"} +``` + +--- + +## 将来の発展方向 + +### WebSocketの次の選択肢 + +| 技術 | 特徴 | 向いている用途 | +|---|---|---| +| WebSocket | TCPベース、順序保証 | チャット、蛇ゲーム | +| WebRTC DataChannel | P2PまたはUDPライク | FPSなど低遅延重視 | +| HTTP/2 Server-Sent Events | サーバー→クライアントのみ | 通知、株価 | + +蛇ゲームのように「順番が重要で少しの遅延は許容できる」場合は WebSocket で十分です。 + +--- + +## 練習問題 + +1. **計測:** JSONのサイズをログに表示して、プレイヤー数との関係を確認する +2. **計測:** 1秒あたりの合計転送量を計算する +3. **実装:** プレイヤー名などの「頻繁に変わらないデータ」を別のメッセージに分離する +4. **発展:** 差分送信を実装して、全量送信との比較をする + +--- + +## 理解確認クイズ + +1. なぜ全量送信は問題になるのですか? +2. 差分送信とは何ですか?デメリットはありますか? +3. JSON以外の軽量フォーマットを2つ挙げてください +4. 「視野内のデータだけ送る」はどのようなゲームに有効ですか? +5. tick間隔を33msにするとデータ量はどうなりますか? + +--- + +## カリキュラムのまとめ + +Step 11 でこのカリキュラムの本体は終わりです。 + +ここまで学んだことを振り返ってください: + +- [ ] WebSocketの接続と基本イベントを説明できる +- [ ] ブロードキャストの仕組みを実装できる +- [ ] 「入力送信」と「状態送信」の違いを説明できる +- [ ] サーバー権威型設計を理解している +- [ ] tickベースのゲームループを実装できる +- [ ] 遅延やカクつきの原因を説明できる +- [ ] 補間の役割を理解している +- [ ] 10人部屋の基本設計を考えられる +- [ ] 切断・再接続・ルーム分割など、現実の問題を意識できる + +次の Step 12 は発展的な話題(複数サーバー・Redis・UDP)です。 diff --git a/step-11-optimization/client/index.html b/step-11-optimization/client/index.html new file mode 100644 index 0000000..27be3f7 --- /dev/null +++ b/step-11-optimization/client/index.html @@ -0,0 +1,272 @@ + + + + + Step 11: 最適化 + + + +

Step 11: 最適化を考える

+ +
+ + +
+ + +
+
+
全量サイズ(サーバー計測)
+
-
+
bytes / tick
+
+
+
実際の受信サイズ
+
-
+
bytes / メッセージ
+
+
+
軽量版サイズ(推定)
+
-
+
bytes / tick
+
+
+
削減率
+
-
+
%削減
+
+
+
プレイヤー数
+
0
+
+
+
+
推定帯域(全量)
+
-
+
bytes/秒
+
+
+
Tick
+
0
+
+
+
+ + + + + + +

+ 複数タブを開いてプレイヤーを増やすと全量サイズが増えます
+ 矢印キーで移動 / サーバーのログでも比較できます +

+ + + + diff --git a/step-11-optimization/server/go.mod b/step-11-optimization/server/go.mod new file mode 100644 index 0000000..ca19728 --- /dev/null +++ b/step-11-optimization/server/go.mod @@ -0,0 +1,20 @@ +module step11-optimization + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.11.4 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/step-11-optimization/server/main.go b/step-11-optimization/server/main.go new file mode 100644 index 0000000..b32c2bf --- /dev/null +++ b/step-11-optimization/server/main.go @@ -0,0 +1,313 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +const ( + tickRate = 100 * time.Millisecond + fieldWidth = 800 + fieldHeight = 600 + gridSize = 20 +) + +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// FullSnake は全量送信用の蛇データ +type FullSnake struct { + ID string `json:"id"` + Body []Point `json:"body"` + Direction string `json:"direction"` + Color string `json:"color"` +} + +// LightSnake は最適化版の蛇データ(フィールド名を短縮) +// "body" → "b", "direction" → "d", "color" → "c" +type LightSnake struct { + ID string `json:"i"` + B []Point `json:"b"` + D string `json:"d"` + C string `json:"c"` +} + +// FullState は全量送信のゲーム状態 +type FullState struct { + Type string `json:"type"` + Snakes map[string]*FullSnake `json:"snakes"` + MyID string `json:"myId,omitempty"` +} + +// LightState は最適化版のゲーム状態 +type LightState struct { + T string `json:"t"` // type + S map[string]*LightSnake `json:"s"` // snakes + MyID string `json:"myId,omitempty"` +} + +// 統計情報をクライアントに送る(デバッグ用) +type StatsMessage struct { + Type string `json:"type"` + FullSize int `json:"fullSize"` // 全量のバイト数 + LightSize int `json:"lightSize"` // 軽量版のバイト数 + PlayerCount int `json:"playerCount"` // 現在のプレイヤー数 + TickCount int64 `json:"tickCount"` +} + +var ( + snakes = make(map[string]*FullSnake) + clients = make(map[string]*websocket.Conn) + mu sync.RWMutex + counter int + tickCount int64 +) + +var playerColors = []string{ + "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", + "#ffeaa7", "#dda0dd", "#98d8c8", "#f7dc6f", +} + +func main() { + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Static("/", "../client") + e.GET("/ws", handleWebSocket) + + go gameLoop() + + fmt.Println("サーバー起動: http://localhost:8080") + fmt.Println("全量送信と軽量版のサイズ比較ができます") + log.Fatal(e.Start(":8080")) +} + +func gameLoop() { + ticker := time.NewTicker(tickRate) + defer ticker.Stop() + for { + <-ticker.C + updateGame() + broadcastWithStats() + } +} + +func updateGame() { + mu.Lock() + defer mu.Unlock() + tickCount++ + for _, s := range snakes { + moveSnake(s) + } +} + +func moveSnake(s *FullSnake) { + if len(s.Body) == 0 { + return + } + h := s.Body[0] + var nh Point + switch s.Direction { + case "up": + nh = Point{h.X, h.Y - gridSize} + case "down": + nh = Point{h.X, h.Y + gridSize} + case "left": + nh = Point{h.X - gridSize, h.Y} + case "right": + nh = Point{h.X + gridSize, h.Y} + } + if nh.X < 0 { + nh.X = fieldWidth - gridSize + } + if nh.X >= fieldWidth { + nh.X = 0 + } + if nh.Y < 0 { + nh.Y = fieldHeight - gridSize + } + if nh.Y >= fieldHeight { + nh.Y = 0 + } + s.Body = append([]Point{nh}, s.Body...) + if len(s.Body) > 10 { + s.Body = s.Body[:10] + } +} + +// broadcastWithStats は全量と軽量両方を計算してサイズ比較統計も送る +func broadcastWithStats() { + mu.RLock() + sc := make(map[string]*FullSnake, len(snakes)) + for id, s := range snakes { + cp := *s + bc := make([]Point, len(s.Body)) + copy(bc, s.Body) + cp.Body = bc + sc[id] = &cp + } + cc := make(map[string]*websocket.Conn, len(clients)) + for id, conn := range clients { + cc[id] = conn + } + count := len(snakes) + tick := tickCount + mu.RUnlock() + + if len(cc) == 0 { + return + } + + // --- 全量送信版 --- + fullMsg := FullState{Type: "state", Snakes: sc} + fullData, err := json.Marshal(fullMsg) + if err != nil { + log.Printf("broadcastWithStats: fullMsg marshal エラー: %v", err) + return + } + + // --- 軽量版(フィールド名短縮)--- + lightSnakes := make(map[string]*LightSnake, len(sc)) + for id, s := range sc { + lightSnakes[id] = &LightSnake{ + ID: s.ID, + B: s.Body, + D: s.Direction, + C: s.Color, + } + } + lightMsg := LightState{T: "state", S: lightSnakes} + lightData, err := json.Marshal(lightMsg) + if err != nil { + log.Printf("broadcastWithStats: lightMsg marshal エラー: %v", err) + return + } + + // --- 統計情報 --- + stats := StatsMessage{ + Type: "stats", + FullSize: len(fullData), + LightSize: len(lightData), + PlayerCount: count, + TickCount: tick, + } + statsData, err := json.Marshal(stats) + if err != nil { + log.Printf("broadcastWithStats: stats marshal エラー: %v", err) + return + } + + // サーバーでもログに出す(一定間隔で) + if tick%50 == 0 { + // fullData が空の場合のゼロ除算を防ぐ + var ratio float64 + if len(fullData) > 0 { + ratio = float64(len(lightData)) / float64(len(fullData)) * 100 + } + log.Printf("tick %d: full=%d bytes, light=%d bytes, ratio=%.1f%%, players=%d", + tick, len(fullData), len(lightData), ratio, count) + } + + // 送信エラーが起きた接続を後でまとめて削除するためのリスト + var failed []string + for id, conn := range cc { + if err := conn.WriteMessage(websocket.TextMessage, fullData); err != nil { + log.Printf("broadcastWithStats: 送信エラー(切断扱い): %s: %v", id, err) + failed = append(failed, id) + continue + } + if err := conn.WriteMessage(websocket.TextMessage, statsData); err != nil { + log.Printf("broadcastWithStats: stats 送信エラー: %s: %v", id, err) + failed = append(failed, id) + } + } + + // 送信失敗した接続を削除する + if len(failed) > 0 { + mu.Lock() + for _, id := range failed { + if conn, ok := clients[id]; ok { + conn.Close() + } + delete(clients, id) + delete(snakes, id) + } + mu.Unlock() + } +} + +type ClientMessage struct { + Type string `json:"type"` + Direction string `json:"direction"` +} + +func handleWebSocket(c echo.Context) error { + conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) + if err != nil { + return err + } + defer conn.Close() + + mu.Lock() + counter++ + id := fmt.Sprintf("snake-%d", counter) + color := playerColors[(counter-1)%len(playerColors)] + body := []Point{} + for i := 0; i < 10; i++ { + body = append(body, Point{ + float64((10-i) * gridSize), + float64((counter-1)%10*3*gridSize + gridSize), + }) + } + snakes[id] = &FullSnake{ID: id, Body: body, Direction: "right", Color: color} + clients[id] = conn + mu.Unlock() + + ack, err := json.Marshal(FullState{Type: "state", MyID: id, Snakes: map[string]*FullSnake{}}) + if err != nil { + log.Printf("ack: json.Marshal エラー: %v", err) + } else if err := conn.WriteMessage(websocket.TextMessage, ack); err != nil { + log.Printf("ack 送信エラー: %v", err) + } + + defer func() { + mu.Lock() + delete(snakes, id) + delete(clients, id) + mu.Unlock() + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + var msg ClientMessage + if json.Unmarshal(raw, &msg) != nil { + continue + } + if msg.Type == "input" { + opposites := map[string]string{"up": "down", "down": "up", "left": "right", "right": "left"} + mu.Lock() + if s, ok := snakes[id]; ok && opposites[s.Direction] != msg.Direction { + s.Direction = msg.Direction + } + mu.Unlock() + } + } + return nil +} diff --git a/step-12-advanced/README.md b/step-12-advanced/README.md new file mode 100644 index 0000000..cde2be4 --- /dev/null +++ b/step-12-advanced/README.md @@ -0,0 +1,234 @@ +# Step 12: 上級発展 — 1台を超える設計へ + +## このステップの目標 + +- 1台構成の限界を理解する +- 複数サーバー構成の発想を掴む +- Redis Pub/Sub の役割を理解する +- WebSocketの次の選択肢を知る + +--- + +## ここまでのおさらい + +このカリキュラムを通じて、以下を実装しました。 + +``` +Step 1: WebSocket接続 +Step 2: ブロードキャスト(1対多) +Step 3: 入力送信(意図を送る) +Step 4: サーバー状態管理(真実はサーバーに) +Step 5: ゲームループ(tickベース更新) +Step 6: 蛇ゲーム +Step 7: 遅延・同期ズレの体験 +Step 8: 補間で滑らかに見せる +Step 9: 10人部屋(Room分割) +Step 10: 切断・再接続(Heartbeat) +Step 11: 最適化(差分送信・軽量化) +``` + +--- + +## 1台構成の限界 + +今作ったサーバーは**1台のプロセス**です。 + +``` +限界: + CPUコア数の限界 + → Goは並行処理が得意だが、1プロセスの限界はある + + メモリの限界 + → 蛇1匹が body 100マス × 2 × 8バイト = 1600バイト + → 10000人が同時プレイ → 16MB(メモリは問題ないが...) + → 問題は CPU:10000人分の tick 計算 + + ネットワーク帯域の限界 + → 1台のNICの上限を超えると詰まる + +現実的な目安: + 適切に実装された1台のGoサーバーで同時 500〜2000 接続は処理できる + それ以上は複数台が必要 +``` + +--- + +## 複数サーバー構成 + +### 問題:WebSocketの「接続が特定サーバーに固定される」性質 + +``` +通常のHTTPはステートレスなので、どのサーバーに振り分けてもOK。 +でも WebSocket は「接続を維持」するため、 +一度繋いだサーバーからしかメッセージを受け取れない。 + +クライアントA ──→ サーバー1(部屋1を担当) +クライアントB ──→ サーバー2(部屋2を担当) + +サーバー1とサーバー2は別プロセス。 +直接通信できない。 +``` + +### 解決策の1つ:部屋ごとにサーバーを割り当てる + +``` +部屋1 → サーバー1 が担当 +部屋2 → サーバー2 が担当 +部屋3 → サーバー1 が担当(空いていれば) + +→ 同じ部屋のプレイヤーは必ず同じサーバーに繋がる +→ ブロードキャストはサーバー内で完結 +``` + +--- + +## Redis Pub/Sub の役割 + +部屋が複数サーバーをまたぐ場合や、サーバー間で情報を共有したいときに Redis が登場します。 + +``` +Redis Pub/Sub の仕組み: + +サーバー1 → Redis に publish(「部屋1でこのイベントが起きた」) +サーバー2 → Redis を subscribe(「部屋1のイベント」を購読) + → サーバー2のクライアントにも届く + +活用例: +- グローバルランキングの更新通知 +- 部屋を超えたフレンドへの通知 +- サーバー間のヘルスチェック +``` + +``` +蛇ゲームなら: + - 各部屋は1台のサーバーが担当するのでRedisは不要 + - でも「全サーバーに繋がっている全プレイヤー数」などは Redis が便利 +``` + +--- + +## WebSocket の限界と次の選択肢 + +### WebSocket の弱点 + +``` +1. TCPベースの「順序保証」が足かせになることがある + + TCPは「順番通りに届ける」ために、前のパケットが届くまで次を待つ + → Head-of-line blocking(行列の先頭でのブロッキング) + +2. 遅延に敏感なゲームには厳しい + + FPS(ファーストパーソンシューティング)のように + 「20ms以上の遅延で体験が壊れる」ゲームには不向き +``` + +### 次の選択肢 + +| 技術 | 特徴 | 向いている用途 | +|---|---|---| +| **WebSocket** | TCPベース、順序保証、信頼性 | チャット、蛇ゲーム、カード系 | +| **WebRTC DataChannel (UDP)** | P2P、UDPライク、低遅延 | FPS、格ゲー、シューティング | +| **QUIC / HTTP/3** | UDP + 信頼性を両立、低遅延 | 次世代の選択肢 | +| **Server-Sent Events** | サーバー→クライアントのみ | 通知、ニュース、株価 | + +### WebRTC DataChannel とは + +``` +ブラウザ同士がP2P接続して直接通信できる仕組み。 +UDPライクな「信頼性なし」チャネルも使える。 + +→ 遅延が数ms短縮される +→ サーバーのボトルネックを回避できる +→ 実装が複雑(シグナリングサーバーが必要) +``` + +--- + +## 蛇ゲームの「本物」はもっと複雑 + +Slither.io の実際の実装(推測ベース): + +``` +1. マップは巨大(スクロールする) +2. 視野内のデータだけ送る(空間ハッシュで絞る) +3. 蛇の体は「key point」だけ送って、クライアントで曲線を計算する +4. 衝突判定は粗い当たり判定 + 詳細判定の2段階 +5. 接続数は膨大なので、ロードバランサー + 複数サーバーは必須 +``` + +--- + +## 今後の学習ロードマップ + +このカリキュラムを終えた後の推奨順序: + +### フェーズ1: 今のゲームを磨く(〜2週間) +- [ ] 壁・自分の体・他プレイヤーへの衝突判定 +- [ ] スコアの永続化(SQLite など) +- [ ] より滑らかな補間(Cubic Bezier など) + +### フェーズ2: スケールを学ぶ(〜1ヶ月) +- [ ] Redis Pub/Sub でのサーバー間通信 +- [ ] ロードバランサーの設定(nginx) +- [ ] Docker でのコンテナ化 + +### フェーズ3: より低遅延を目指す(〜3ヶ月) +- [ ] WebRTC DataChannel の基礎 +- [ ] UDP と TCP の違いを実験で確認 +- [ ] Client-Side Prediction の実装 + +### フェーズ4: プロダクション品質(継続) +- [ ] 監視・ログ・アラート +- [ ] 負荷テスト(k6, wrk など) +- [ ] セキュリティ(認証、レートリミット) + +--- + +## まとめ:WebSocketは「入口」 + +このカリキュラムで学んだことは、リアルタイム通信の入口です。 + +``` +でも、この「入口」をちゃんと通ったことで、 +次に何を学べばいいかが見えてくるはずです。 + +「サーバー権威型設計」を理解した +→ ゲームだけでなく、ドローン制御や金融取引にも同じ発想が使える + +「補間と予測」を理解した +→ 「正確さ」と「体験の良さ」のトレードオフを考えられる + +「遅延と同期ズレ」を体験した +→ 「なぜリアルタイムアプリは難しいのか」が分かった + +この視点は、将来どんな技術を使っても役に立ちます。 +``` + +--- + +## 理解確認クイズ + +1. 1台構成のWebSocketサーバーの限界は何ですか? +2. なぜWebSocketは「特定サーバーに固定」されるのですか? +3. Redis Pub/Sub はどんな問題を解決しますか? +4. WebSocketとWebRTC DataChannelの一番の違いは何ですか? +5. このカリキュラムで一番重要だったステップはどれですか?(理由も) + +--- + +## 最後のメッセージ + +カリキュラムを読み流しただけでは頭に残りません。 + +**もう一度、自分の言葉で書いてください:** + +``` +1. WebSocketとHTTPの違いを3行で +2. なぜクライアントに座標を計算させてはいけないのかを1段落で +3. ゲームループが必要な理由を2行で +4. 補間が何を解決するのかを3行で +5. このカリキュラムで一番「なるほど」と思ったことを自由に +``` + +これを書けたら、本当に理解できています。