diff --git a/.github/workflows/addressables-deploy.yml b/.github/workflows/addressables-deploy.yml index 69b0459f4..af0d8de72 100644 --- a/.github/workflows/addressables-deploy.yml +++ b/.github/workflows/addressables-deploy.yml @@ -68,6 +68,8 @@ jobs: outputs: unity_project_path: ${{ steps.config.outputs.UNITY_PROJECT_PATH }} build_timestamp: ${{ steps.timestamp.outputs.value }} + # build / deploy 両方で使う matrix include 配列を動的生成(job-level `if` で matrix を参照できないため) + build_matrix: ${{ steps.matrix.outputs.value }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -78,6 +80,22 @@ jobs: id: timestamp run: echo "value=$(date +'%Y%m%d_%H%M%S')" >> $GITHUB_OUTPUT + - name: Generate build matrix + id: matrix + run: | + # 対応 runner があるプラットフォームのみ matrix に含める + # WebGL / Android / macOS / iOS は scope 外(runner 未配置)→ 入力されても matrix が空 → job スキップ + WIN='{"platform":"StandaloneWindows64","build_target":"Win64","runner_label":"windows-mono"}' + LINUX='{"platform":"StandaloneLinux64","build_target":"Linux64","runner_label":"linux-il2cpp"}' + case "${{ inputs.build_target }}" in + All) MATRIX="[${WIN},${LINUX}]" ;; + StandaloneWindows64) MATRIX="[${WIN}]" ;; + StandaloneLinux64) MATRIX="[${LINUX}]" ;; + *) MATRIX="[]" ;; + esac + echo "value=${MATRIX}" >> "$GITHUB_OUTPUT" + echo "Generated matrix: ${MATRIX}" + - name: Load configuration id: config run: | @@ -89,13 +107,16 @@ jobs: # ============================================ build-addressables: name: Build Addressables (${{ matrix.platform }}) - runs-on: [self-hosted, linux, unity, docker] + # platform 別に self-hosted runner を振り分け(matrix.runner_label で AND マッチング) + runs-on: [self-hosted, linux, unity, docker, "${{ matrix.runner_label }}"] needs: load-config - timeout-minutes: 180 + timeout-minutes: 720 strategy: fail-fast: false + # load-config が input に応じて生成した matrix を使う + # (job-level `if` で matrix context は使えないため、matrix 自体を空にして job をスキップする方式) matrix: - platform: ${{ fromJSON(inputs.build_target == 'All' && '["StandaloneWindows64", "StandaloneLinux64", "WebGL", "Android"]' || format('["{0}"]', inputs.build_target)) }} + include: ${{ fromJSON(needs.load-config.outputs.build_matrix) }} env: UNITY_PROJECT_PATH: ${{ needs.load-config.outputs.unity_project_path }} BUILD_TIMESTAMP: ${{ needs.load-config.outputs.build_timestamp }} @@ -108,23 +129,10 @@ jobs: lfs: false clean: false - - name: Get Unity build target - id: build-target - run: | - case "${{ matrix.platform }}" in - StandaloneWindows64) echo "target=Win64" >> $GITHUB_OUTPUT ;; - StandaloneLinux64) echo "target=Linux64" >> $GITHUB_OUTPUT ;; - StandaloneOSX) echo "target=OSXUniversal" >> $GITHUB_OUTPUT ;; - WebGL) echo "target=WebGL" >> $GITHUB_OUTPUT ;; - Android) echo "target=Android" >> $GITHUB_OUTPUT ;; - iOS) echo "target=iOS" >> $GITHUB_OUTPUT ;; - *) echo "target=${{ matrix.platform }}" >> $GITHUB_OUTPUT ;; - esac - - name: Setup Library cache run: | chmod +x .github/scripts/setup-library-cache.sh - .github/scripts/setup-library-cache.sh "${{ env.UNITY_PROJECT_PATH }}" "${{ steps.build-target.outputs.target }}" + .github/scripts/setup-library-cache.sh "${{ env.UNITY_PROJECT_PATH }}" "${{ matrix.build_target }}" - name: Setup directories run: | @@ -187,7 +195,7 @@ jobs: -nographics \ -quit \ -projectPath "${{ env.UNITY_PROJECT_PATH }}" \ - -buildTarget "${{ steps.build-target.outputs.target }}" \ + -buildTarget "${{ matrix.build_target }}" \ $ACCELERATOR_ARGS \ -executeMethod Game.Editor.Build.AddressablesR2Uploader.BuildAddressablesCI \ -logFile "${{ github.workspace }}/Logs/addressables-build-${{ matrix.platform }}.log" @@ -305,14 +313,16 @@ jobs: # ============================================ deploy-to-r2: name: Deploy to R2 (${{ matrix.platform }}) + # 軽量タスク(rclone のみ)。任意の self-hosted runner で実行可 runs-on: [self-hosted, linux, unity, docker] needs: [load-config, build-addressables] if: inputs.deploy == true timeout-minutes: 360 strategy: fail-fast: false + # build-addressables と同じ matrix を共有(platform フィールドのみ参照) matrix: - platform: ${{ fromJSON(inputs.build_target == 'All' && '["StandaloneWindows64", "StandaloneLinux64", "WebGL", "Android"]' || format('["{0}"]', inputs.build_target)) }} + include: ${{ fromJSON(needs.load-config.outputs.build_matrix) }} env: BUILD_TIMESTAMP: ${{ needs.load-config.outputs.build_timestamp }} @@ -475,7 +485,7 @@ jobs: echo "### Deployed URLs" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ "${{ inputs.build_target }}" == "All" ]; then - for platform in StandaloneWindows64 StandaloneLinux64 WebGL Android; do + for platform in StandaloneWindows64 StandaloneLinux64; do echo "- **${platform}:** https://${{ env.R2_CUSTOM_DOMAIN }}/${platform}/" >> $GITHUB_STEP_SUMMARY done else diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index be88898fa..b4958e03a 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -103,7 +103,7 @@ jobs: # ============================================ test: name: Run Tests - runs-on: [self-hosted, linux, unity, docker] + runs-on: [self-hosted, linux, unity, docker, linux-il2cpp] needs: load-config if: ${{ !inputs.skip_tests }} timeout-minutes: 180 @@ -171,7 +171,7 @@ jobs: # ============================================ build-windows: name: Build Windows - runs-on: [self-hosted, linux, unity, docker] + runs-on: [self-hosted, linux, unity, docker, windows-mono] needs: [load-config, test] if: | always() && @@ -237,7 +237,7 @@ jobs: # ============================================ build-linux: name: Build Linux - runs-on: [self-hosted, linux, unity, docker] + runs-on: [self-hosted, linux, unity, docker, linux-il2cpp] needs: [load-config, test] if: | always() && @@ -303,7 +303,8 @@ jobs: # ============================================ build-macos: name: Build macOS - runs-on: [self-hosted, linux, unity, docker] + # scope 外: 対応する mac-mono runner は未配置。実行すると queue 待機 → cancel を想定 + runs-on: [self-hosted, linux, unity, docker, mac-mono] needs: [load-config, test] if: | always() && @@ -369,7 +370,8 @@ jobs: # ============================================ build-webgl: name: Build WebGL - runs-on: [self-hosted, linux, unity, docker] + # scope 外: 対応する webgl runner は未配置。実行すると queue 待機 → cancel を想定 + runs-on: [self-hosted, linux, unity, docker, webgl] needs: [load-config, test] if: | always() && @@ -435,7 +437,8 @@ jobs: # ============================================ build-android: name: Build Android - runs-on: [self-hosted, linux, unity, docker] + # scope 外: 対応する android runner は未配置。実行すると queue 待機 → cancel を想定 + runs-on: [self-hosted, linux, unity, docker, android] needs: [load-config, test] if: | always() && @@ -501,7 +504,8 @@ jobs: # ============================================ build-ios: name: Build iOS (Xcode Project) - runs-on: [self-hosted, linux, unity, docker] + # scope 外: 対応する ios runner は未配置。実行すると queue 待機 → cancel を想定 + runs-on: [self-hosted, linux, unity, docker, ios] needs: [load-config, test] if: | always() && diff --git a/.github/workflows/unity-server-build.yml b/.github/workflows/unity-server-build.yml index 02fedcc80..bc6bf639f 100644 --- a/.github/workflows/unity-server-build.yml +++ b/.github/workflows/unity-server-build.yml @@ -90,7 +90,7 @@ jobs: # ============================================ test: name: Run Tests - runs-on: [self-hosted, linux, unity, docker] + runs-on: [self-hosted, linux, unity, docker, linux-il2cpp] needs: load-config if: ${{ !inputs.skip_tests }} timeout-minutes: 180 @@ -149,7 +149,8 @@ jobs: # ============================================ build-windows-server: name: Build Windows Dedicated Server - runs-on: [self-hosted, linux, unity, docker] + # windows-mono image は win64_server_*_mono バリアントを内包するため windows-mono runner で対応可 + runs-on: [self-hosted, linux, unity, docker, windows-mono] needs: [load-config, test] if: | always() && @@ -215,7 +216,8 @@ jobs: # ============================================ build-linux-server: name: Build Linux Dedicated Server - runs-on: [self-hosted, linux, unity, docker] + # linux-il2cpp image は linux64_server_*_il2cpp / mono バリアントを内包するため linux-il2cpp runner で対応可 + runs-on: [self-hosted, linux, unity, docker, linux-il2cpp] needs: [load-config, test] if: | always() && diff --git a/.github/workflows/unity-test.yml b/.github/workflows/unity-test.yml index 7da47657f..cb02119db 100644 --- a/.github/workflows/unity-test.yml +++ b/.github/workflows/unity-test.yml @@ -50,7 +50,7 @@ jobs: # ============================================ test-editmode: name: EditMode Tests - runs-on: [self-hosted, linux, unity, docker] + runs-on: [self-hosted, linux, unity, docker, linux-il2cpp] needs: load-config timeout-minutes: 30 env: @@ -114,7 +114,7 @@ jobs: # ============================================ test-playmode: name: PlayMode Tests - runs-on: [self-hosted, linux, unity, docker] + runs-on: [self-hosted, linux, unity, docker, linux-il2cpp] needs: [load-config, test-editmode] timeout-minutes: 120 env: diff --git a/README.en.md b/README.en.md index 63914036d..be330e355 100644 --- a/README.en.md +++ b/README.en.md @@ -4,12 +4,14 @@ A game development portfolio built with Unity 6 + ASP.NET Core 9 + MagicOnion gR ## Highlights -* **8-assembly modular design** — MVC/MVP coexistence with structurally enforced circular reference prevention -* **1,148 automated tests** (EditMode 746 + PlayMode 63 + Server 339) across 7 CI/CD workflows +* **Unity × Server × Infrastructure in a single monorepo** — Unity 6 client / ASP.NET Core 9 + MagicOnion gRPC / PostgreSQL + Valkey / GitHub Actions CI/CD +* **Photon Fusion 2 server authority model + Dedicated Server operations** — Dead Reckoning interpolation, enemy batch sync (NetworkArray<512>), Linux headless build with self-registration + HMAC auth + Docker deployment +* **Self-built LiveOps delivery pipeline** — GitHub Actions self-hosted runners + Unity Accelerator + Cloudflare R2 CDN, Addressables with 4-environment switching, index.json differential sync, editor auto-sync * **ECS + Burst parallelization** — up to 20.3x speedup for enemy spawn calculations (5,000 entities), 23 ProfilerMarkers for instrumentation -* **Protobuf schema-driven master data** — custom CLI tool (6 subcommands), deploy-target-filtered binary generation from a single schema for Client/Server -* **Circuit breaker + exponential backoff retry** for fault-tolerant HTTP communication with cache fallback -* **Photon Fusion 2 server authority model** — Dead Reckoning interpolation, enemy batch sync (NetworkArray<512>), Dedicated Server orchestration +* **Rendering optimization targeting mobile device quality** — custom URP/HLSL shaders (ToonLit / Dissolve / Hit Flash / Outline), distance-based 3-tier LOD, dual URP profiles, Canvas separation +* **Protobuf schema-driven master data** — custom CLI tool (6 subcommands), deploy-target-filtered binary generation from a single schema for Client/Server/Realtime +* **8-assembly modular design** — MVC/MVP coexistence with structurally enforced circular reference prevention +* **1,148 automated tests** (EditMode 746 + PlayMode 63 + Server 339 with Testcontainers) across 7 CI/CD workflows > **Architecture Details**: [ARCHITECTURE.md](ARCHITECTURE.md) (11 chapters, 14 ADRs) @@ -194,7 +196,7 @@ dotnet test * **Real-time Chat**: Room-based messaging via SignalR WebSocket + MagicOnion * **Request Signing Policy**: Declarative endpoint security (3 signing attributes), fail-fast startup validation -### Multiplayer (Photon Fusion 2) +### Realtime Online Gameplay (Photon Fusion 2) * **Server Authority Model**: Server/Client mode, [Networked] properties, Fusion FSM player state sync * **Enemy Batch Sync**: Server-controlled enemy AI, 10Hz batch sync (NetworkArray<512>), client Dead Reckoning interpolation * **Dedicated Server Orchestration**: Linux headless build, self-registration + heartbeat to Game.Server, HMAC auth, Docker deploy @@ -1001,14 +1003,13 @@ Unity6Portfolio/ * DOTween Sequence compound UI animations (level-up, result screens) * Skip/interrupt control (DOKill / IsTweening guard) implementation -**Design Patterns:** -* ScriptableObject event channel introduction (comparison with MessagePipe) -* ScriptableObject data assets for runtime configuration - **Platform:** * Localization support (multi-language, Unity Localization) * Multi-resolution & multi-platform support (iOS / Android build & signing) +**Features:** +* In-app purchase system, gacha, present box, etc. + --- ## About the Demo Game diff --git a/README.md b/README.md index 43dbcfd3f..2550baa14 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ Unity 6 + ASP.NET Core 9 + MagicOnion gRPC + Photon Fusion 2 によるゲーム ## ハイライト -* **8アセンブリ分割**のモジュラー設計 — MVC/MVP両パターンを共存させ、循環参照を構造的に防止 -* **1,148テスト**による自動品質保証(EditMode 746 + PlayMode 63 + サーバー 339)、CI/CD 7ワークフロー +* **Unity × サーバー × インフラをモノレポで一括実装** — Unity 6 クライアント / ASP.NET Core 9 + MagicOnion gRPC / PostgreSQL + Valkey / GitHub Actions CI/CD +* **Photon Fusion 2 サーバー権威モデル + Dedicated Server運用** — Dead Reckoning補間、敵バッチ同期(NetworkArray<512>)、Linuxヘッドレスビルド自己登録+HMAC認証+Docker化 +* **LiveOps配信基盤の自力構築** — GitHub Actions セルフホストランナー + Unity Accelerator + Cloudflare R2 CDN、Addressables 4環境切替・index.json差分同期・エディタ自動同期 * **ECS + Burst並列化**で敵スポーン計算を最大20.3倍高速化(5,000体)、23箇所のProfilerMarkerによる計測基盤 -* **Protobufスキーマ駆動**のマスターデータ基盤 — CLIツール自作(6サブコマンド)、Client/Server同一スキーマからデプロイターゲット別バイナリ生成 -* **サーキットブレーカー + 指数バックオフリトライ**によるHTTP通信の耐障害設計、キャッシュフォールバック対応 -* **Photon Fusion 2 サーバー権威モデル** — Dead Reckoning補間、敵バッチ同期(NetworkArray<512>)、Dedicated Serverオーケストレーション +* **モバイル実機品質を意識したレンダリング最適化** — カスタムURP/HLSLシェーダー(ToonLit / Dissolve / Hit Flash / Outline)、距離ベース3段階LOD、URP 2プロファイル、Canvas分離 +* **Protobufスキーマ駆動のマスターデータ基盤** — CLIツール自作(6サブコマンド)、Client/Server/Realtime同一スキーマからデプロイターゲット別バイナリ生成 +* **8アセンブリ分割のモジュラー設計** — MVC/MVP両パターンを共存させ、循環参照を構造的に防止 +* **1,148テスト**による自動品質保証(EditMode 746 + PlayMode 63 + サーバー 339・Testcontainers採用)、CI/CD 7ワークフロー > **アーキテクチャ詳細**: [ARCHITECTURE.md](ARCHITECTURE.md)(全11章、ADR 14件) @@ -194,7 +196,7 @@ dotnet test * **リアルタイムチャット**: SignalR WebSocket + MagicOnionによるルームベースメッセージング * **リクエスト署名ポリシー**: 宣言的エンドポイントセキュリティ(3種の署名属性)、起動時fail-fastバリデーション -### サーバー/クライアントモデル(Photon Fusion 2) +### リアルタイムオンラインゲームプレイ(Photon Fusion 2) * **サーバー権威モデル**: Server/Clientモード、[Networked]プロパティ、Fusion FSMによるプレイヤーステート同期 * **敵バッチ同期**: サーバーが敵AI制御、10Hzバッチ同期(NetworkArray<512>)、クライアントDead Reckoning補間 * **Dedicated Serverオーケストレーション**: Linux ヘッドレスビルド、Game.Serverへの自己登録+ハートビート、HMAC認証、Dockerデプロイ @@ -1008,14 +1010,13 @@ Unity6Portfolio/ * DOTween Sequenceによる複合UI演出(レベルアップ、リザルト) * スキップ・割り込み制御(DOKill / IsTweening ガード)の実装 -**設計パターン:** -* ScriptableObjectイベントチャネルの導入(MessagePipeとの使い分け検証) -* ScriptableObjectデータアセットの追加(ランタイム設定用途) - **プラットフォーム:** * ローカライズ対応(多言語、Unity Localization) * マルチ解像度・マルチプラットフォーム対応(iOS / Androidビルド・署名) +**機能:** +* 課金システム・ガチャ・プレゼントBOXなど + --- ## デモゲームについて diff --git a/docker/game-realtime/prod/cloudbuild.yml b/docker/game-realtime/prod/cloudbuild.yml index c0c1089ff..fb5809414 100644 --- a/docker/game-realtime/prod/cloudbuild.yml +++ b/docker/game-realtime/prod/cloudbuild.yml @@ -54,7 +54,10 @@ steps: --region=${_REGION} \ --platform=managed \ --allow-unauthenticated \ - --vpc-connector=${_VPC_CONNECTOR} \ + --clear-vpc-connector \ + --network=${_VPC_NETWORK} \ + --subnet=${_VPC_SUBNET} \ + --vpc-egress=private-ranges-only \ --set-env-vars="ASPNETCORE_ENVIRONMENT=Production" \ --set-env-vars="ConnectionStrings__Valkey=${_VALKEY_HOST}:${_VALKEY_PORT},abortConnect=false,connectTimeout=5000" \ --set-env-vars="Jwt__Issuer=${_JWT_ISSUER}" \ @@ -83,8 +86,9 @@ substitutions: # Memorystore for Valkey 設定 _VALKEY_HOST: '' _VALKEY_PORT: '6379' - # VPC Connector(Memorystore 接続に必要) - _VPC_CONNECTOR: '' + # Direct VPC Egress(Memorystore / 内部通信に必要) + _VPC_NETWORK: default + _VPC_SUBNET: game-direct-vpc-subnet # ===== ビルドオプション ===== options: diff --git a/docker/game-realtime/prod/deploy.ps1 b/docker/game-realtime/prod/deploy.ps1 index 507725de0..4673aba66 100644 --- a/docker/game-realtime/prod/deploy.ps1 +++ b/docker/game-realtime/prod/deploy.ps1 @@ -55,15 +55,21 @@ if (-not $env:Jwt__Secret) { # Check Valkey settings (critical for Game.Realtime) $ValkeyEnabled = $false -if ($env:VALKEY_HOST -and $env:VPC_CONNECTOR) { +if ($env:VALKEY_HOST -and $env:VPC_NETWORK -and $env:VPC_SUBNET) { $ValkeyEnabled = $true -} elseif ($env:VALKEY_HOST -or $env:VPC_CONNECTOR) { - Write-Host "[WARN] Valkey requires both VALKEY_HOST and VPC_CONNECTOR to be set." -ForegroundColor Yellow +} elseif ($env:VALKEY_HOST) { + Write-Host "[WARN] Valkey requires VALKEY_HOST, VPC_NETWORK, and VPC_SUBNET to be set." -ForegroundColor Yellow } if (-not $ValkeyEnabled) { Write-Host "[WARN] Valkey is not configured. Redis backplane for MagicOnion will not work." -ForegroundColor Yellow } +# Check Direct VPC Egress (required for internal communication) +$VpcEgressEnabled = $false +if ($env:VPC_NETWORK -and $env:VPC_SUBNET) { + $VpcEgressEnabled = $true +} + $IMAGE = "$env:REGION-docker.pkg.dev/$env:PROJECT_ID/$env:REPO_NAME/game-realtime" Write-Host "" @@ -74,10 +80,13 @@ Write-Host "SERVICE_NAME: $env:SERVICE_NAME" Write-Host "IMAGE: ${IMAGE}:${Tag}" if ($ValkeyEnabled) { Write-Host "VALKEY: $env:VALKEY_HOST`:$env:VALKEY_PORT" - Write-Host "VPC_CONNECTOR: $env:VPC_CONNECTOR" } else { Write-Host "VALKEY: (not configured)" } +if ($VpcEgressEnabled) { + Write-Host "VPC_NETWORK: $env:VPC_NETWORK" + Write-Host "VPC_SUBNET: $env:VPC_SUBNET" +} Write-Host "MIN_INSTANCES: 1 (StreamingHub persistent connections)" Write-Host "SESSION_AFFINITY: enabled" Write-Host "HTTP/2: enabled (gRPC)" @@ -157,9 +166,12 @@ if (-not $BuildOnly) { "--use-http2" ) - # Add VPC Connector if Valkey is enabled - if ($ValkeyEnabled) { - $DeployArgs += "--vpc-connector=$env:VPC_CONNECTOR" + # Add Direct VPC Egress (clear legacy VPC Connector) + if ($VpcEgressEnabled) { + $DeployArgs += "--clear-vpc-connector" + $DeployArgs += "--network=$env:VPC_NETWORK" + $DeployArgs += "--subnet=$env:VPC_SUBNET" + $DeployArgs += "--vpc-egress=private-ranges-only" } & gcloud @DeployArgs diff --git a/docker/game-realtime/prod/deploy.sh b/docker/game-realtime/prod/deploy.sh index fc6d54e11..f71d8fa4f 100644 --- a/docker/game-realtime/prod/deploy.sh +++ b/docker/game-realtime/prod/deploy.sh @@ -60,15 +60,21 @@ fi # Valkey 設定の確認(Game.Realtime では必須レベル) VALKEY_ENABLED=false -if [[ -n "$VALKEY_HOST" && -n "$VPC_CONNECTOR" ]]; then +if [[ -n "$VALKEY_HOST" && -n "$VPC_NETWORK" && -n "$VPC_SUBNET" ]]; then VALKEY_ENABLED=true -elif [[ -n "$VALKEY_HOST" || -n "$VPC_CONNECTOR" ]]; then - echo "[WARN] Valkey requires both VALKEY_HOST and VPC_CONNECTOR to be set." +elif [[ -n "$VALKEY_HOST" ]]; then + echo "[WARN] Valkey requires VALKEY_HOST, VPC_NETWORK, and VPC_SUBNET to be set." fi if [[ "$VALKEY_ENABLED" != "true" ]]; then echo "[WARN] Valkey is not configured. Redis backplane for MagicOnion will not work." fi +# Direct VPC Egress の確認(内部通信に必要) +VPC_EGRESS_ENABLED=false +if [[ -n "$VPC_NETWORK" && -n "$VPC_SUBNET" ]]; then + VPC_EGRESS_ENABLED=true +fi + IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/game-realtime" echo "" @@ -79,10 +85,13 @@ echo "SERVICE_NAME: $SERVICE_NAME" echo "IMAGE: ${IMAGE}:${TAG}" if [[ "$VALKEY_ENABLED" == "true" ]]; then echo "VALKEY: ${VALKEY_HOST}:${VALKEY_PORT:-6379}" - echo "VPC_CONNECTOR: $VPC_CONNECTOR" else echo "VALKEY: (not configured)" fi +if [[ "$VPC_EGRESS_ENABLED" == "true" ]]; then + echo "VPC_NETWORK: $VPC_NETWORK" + echo "VPC_SUBNET: $VPC_SUBNET" +fi echo "MIN_INSTANCES: 1 (StreamingHub persistent connections)" echo "SESSION_AFFINITY: enabled" echo "HTTP/2: enabled (gRPC)" @@ -154,9 +163,12 @@ if [[ "$BUILD_ONLY" != "true" ]]; then "--use-http2" ) - # VPC Connector を追加(Valkey 有効時) - if [[ "$VALKEY_ENABLED" == "true" ]]; then - DEPLOY_ARGS+=("--vpc-connector=$VPC_CONNECTOR") + # Direct VPC Egress を追加(レガシー VPC Connector をクリア) + if [[ "$VPC_EGRESS_ENABLED" == "true" ]]; then + DEPLOY_ARGS+=("--clear-vpc-connector") + DEPLOY_ARGS+=("--network=$VPC_NETWORK") + DEPLOY_ARGS+=("--subnet=$VPC_SUBNET") + DEPLOY_ARGS+=("--vpc-egress=private-ranges-only") fi gcloud "${DEPLOY_ARGS[@]}" diff --git a/docker/game-server/prod/cloud-sql-proxy.exe b/docker/game-server/prod/cloud-sql-proxy.exe new file mode 100644 index 000000000..a1d9f70ce Binary files /dev/null and b/docker/game-server/prod/cloud-sql-proxy.exe differ diff --git a/docker/game-server/prod/cloudbuild.yml b/docker/game-server/prod/cloudbuild.yml index 69a7aa9ec..18e67de45 100644 --- a/docker/game-server/prod/cloudbuild.yml +++ b/docker/game-server/prod/cloudbuild.yml @@ -49,10 +49,10 @@ steps: --format="value(connectionName)" > /workspace/connection_name.txt # ===== Cloud Run にデプロイ ===== - # 注意: Memorystore for Valkey への接続には VPC Connector が必要 - # 事前に Serverless VPC Access コネクタを作成してください: - # gcloud compute networks vpc-access connectors create CONNECTOR_NAME \ - # --region=REGION --network=VPC_NETWORK --range=10.8.0.0/28 + # 注意: Memorystore for Valkey / GCE 内部通信には Direct VPC Egress が必要 + # 事前にサブネットを作成してください: + # gcloud compute networks subnets create game-direct-vpc-subnet \ + # --network=default --region=asia-northeast1 --range=10.10.0.0/26 - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' id: 'deploy' entrypoint: 'bash' @@ -70,7 +70,10 @@ steps: --platform=managed \ --allow-unauthenticated \ --add-cloudsql-instances=$${CONNECTION_NAME} \ - --vpc-connector=${_VPC_CONNECTOR} \ + --clear-vpc-connector \ + --network=${_VPC_NETWORK} \ + --subnet=${_VPC_SUBNET} \ + --vpc-egress=private-ranges-only \ --set-env-vars="ASPNETCORE_ENVIRONMENT=Production" \ --set-env-vars="Jwt__Issuer=${_JWT_ISSUER}" \ --set-env-vars="Jwt__Audience=${_JWT_AUDIENCE}" \ @@ -97,8 +100,9 @@ substitutions: # 非機密設定 _JWT_ISSUER: Game.Server _JWT_AUDIENCE: Game.Client - # VPC Connector(Memorystore 接続に必要) - _VPC_CONNECTOR: '' + # Direct VPC Egress(Memorystore / 内部通信に必要) + _VPC_NETWORK: default + _VPC_SUBNET: game-direct-vpc-subnet # ===== ビルドオプション ===== options: diff --git a/docker/game-server/prod/deploy.ps1 b/docker/game-server/prod/deploy.ps1 index abb760d1d..5714a8221 100644 --- a/docker/game-server/prod/deploy.ps1 +++ b/docker/game-server/prod/deploy.ps1 @@ -52,10 +52,10 @@ if (-not $CONNECTION_NAME) { exit 1 } -# Check VPC Connector (required for Memorystore access) -$VpcConnectorEnabled = $false -if ($env:VPC_CONNECTOR) { - $VpcConnectorEnabled = $true +# Check Direct VPC Egress (required for Memorystore / internal communication) +$VpcEgressEnabled = $false +if ($env:VPC_NETWORK -and $env:VPC_SUBNET) { + $VpcEgressEnabled = $true } Write-Host "" @@ -66,8 +66,9 @@ Write-Host "SERVICE_NAME: $env:SERVICE_NAME" Write-Host "IMAGE: ${IMAGE}:${Tag}" Write-Host "CLOUD_SQL: $CONNECTION_NAME" Write-Host "DATABASE: $env:DB_NAME" -if ($VpcConnectorEnabled) { - Write-Host "VPC_CONNECTOR: $env:VPC_CONNECTOR" +if ($VpcEgressEnabled) { + Write-Host "VPC_NETWORK: $env:VPC_NETWORK" + Write-Host "VPC_SUBNET: $env:VPC_SUBNET" } Write-Host "VALKEY_SECRET: $env:SECRET_VALKEY_CONNECTION" Write-Host "=================================" -ForegroundColor Cyan @@ -138,9 +139,12 @@ if (-not $BuildOnly) { "--timeout=300" ) - # Add VPC Connector if Valkey is enabled - if ($VpcConnectorEnabled) { - $DeployArgs += "--vpc-connector=$env:VPC_CONNECTOR" + # Add Direct VPC Egress (clear legacy VPC Connector) + if ($VpcEgressEnabled) { + $DeployArgs += "--clear-vpc-connector" + $DeployArgs += "--network=$env:VPC_NETWORK" + $DeployArgs += "--subnet=$env:VPC_SUBNET" + $DeployArgs += "--vpc-egress=private-ranges-only" } & gcloud @DeployArgs diff --git a/docker/game-server/prod/deploy.sh b/docker/game-server/prod/deploy.sh index bc90ba010..aed559690 100644 --- a/docker/game-server/prod/deploy.sh +++ b/docker/game-server/prod/deploy.sh @@ -57,10 +57,10 @@ if [[ -z "$CONNECTION_NAME" ]]; then exit 1 fi -# VPC Connector の確認(Memorystore 接続に必要) -VPC_CONNECTOR_ENABLED=false -if [[ -n "$VPC_CONNECTOR" ]]; then - VPC_CONNECTOR_ENABLED=true +# Direct VPC Egress の確認(Memorystore / 内部通信に必要) +VPC_EGRESS_ENABLED=false +if [[ -n "$VPC_NETWORK" && -n "$VPC_SUBNET" ]]; then + VPC_EGRESS_ENABLED=true fi echo "" @@ -71,8 +71,9 @@ echo "SERVICE_NAME: $SERVICE_NAME" echo "IMAGE: ${IMAGE}:${TAG}" echo "CLOUD_SQL: $CONNECTION_NAME" echo "DATABASE: $DB_NAME" -if [[ "$VPC_CONNECTOR_ENABLED" == "true" ]]; then - echo "VPC_CONNECTOR: $VPC_CONNECTOR" +if [[ "$VPC_EGRESS_ENABLED" == "true" ]]; then + echo "VPC_NETWORK: $VPC_NETWORK" + echo "VPC_SUBNET: $VPC_SUBNET" fi echo "VALKEY_SECRET: $SECRET_VALKEY_CONNECTION" echo "=================================" @@ -131,9 +132,12 @@ if [[ "$BUILD_ONLY" != "true" ]]; then "--timeout=300" ) - # VPC Connector を追加(Valkey 有効時) - if [[ "$VPC_CONNECTOR_ENABLED" == "true" ]]; then - DEPLOY_ARGS+=("--vpc-connector=$VPC_CONNECTOR") + # Direct VPC Egress を追加(レガシー VPC Connector をクリア) + if [[ "$VPC_EGRESS_ENABLED" == "true" ]]; then + DEPLOY_ARGS+=("--clear-vpc-connector") + DEPLOY_ARGS+=("--network=$VPC_NETWORK") + DEPLOY_ARGS+=("--subnet=$VPC_SUBNET") + DEPLOY_ARGS+=("--vpc-egress=private-ranges-only") fi gcloud "${DEPLOY_ARGS[@]}" diff --git a/docker/unity-ci/docker-compose.yml b/docker/unity-ci/docker-compose.yml index bb136b2e6..21c6b5810 100644 --- a/docker/unity-ci/docker-compose.yml +++ b/docker/unity-ci/docker-compose.yml @@ -20,9 +20,10 @@ name: unity-ci services: # ============================================ - # Unity CI Runner (GitHub Actions Self-hosted) + # Unity CI Runner: Windows Mono (GitHub Actions Self-hosted) + # Windows クライアント / Windows DS / Windows Addressables ビルド用 # ============================================ - unity-ci-runner: + unity-ci-windows-mono: build: context: . dockerfile: Dockerfile @@ -30,8 +31,8 @@ services: UNITY_VERSION: ${UNITY_VERSION:-6000.3.8f1} UNITY_MODULE: ${UNITY_MODULE:-windows-mono} IMAGE_VERSION: ${IMAGE_VERSION:-3} - image: unity-ci-runner:${UNITY_VERSION:-6000.3.8f1}-${UNITY_MODULE:-windows-mono} - container_name: unity-ci-runner + image: unity-ci-windows-mono:${UNITY_VERSION:-6000.3.8f1} + container_name: unity-ci-windows-mono restart: unless-stopped environment: @@ -49,8 +50,8 @@ services: - GITHUB_APP_PRIVATE_KEY_BASE64=${GITHUB_APP_PRIVATE_KEY_BASE64:-} # Runner configuration - - RUNNER_NAME=${RUNNER_NAME:-unity-runner} - - RUNNER_LABELS=${RUNNER_LABELS:-self-hosted,linux,unity,docker} + - RUNNER_NAME=${RUNNER_NAME:-unity-ci-windows-mono} + - RUNNER_LABELS=${RUNNER_LABELS:-self-hosted,linux,unity,docker,windows-mono} # Unity Accelerator 接続設定 - UNITY_ACCELERATOR_ENDPOINT=unity-accelerator:10080 @@ -90,6 +91,74 @@ services: networks: - unity-ci-network + # ============================================ + # Unity CI Runner: Linux IL2CPP (GitHub Actions Self-hosted) + # Linux クライアント / Linux DS / Linux Addressables / テスト用 + # `linux-il2cpp` image は Linux Server バリアント(linux64_server_*_il2cpp / mono) + # を内包するため、別途 linux-server runner は不要 + # ============================================ + unity-ci-linux-il2cpp: + build: + context: . + dockerfile: Dockerfile + args: + UNITY_VERSION: ${UNITY_VERSION:-6000.3.8f1} + UNITY_MODULE: linux-il2cpp + IMAGE_VERSION: ${IMAGE_VERSION:-3} + image: unity-ci-linux-il2cpp:${UNITY_VERSION:-6000.3.8f1} + container_name: unity-ci-linux-il2cpp + restart: unless-stopped + + environment: + # Required - Repository + - GITHUB_REPOSITORY=${GITHUB_REPOSITORY} + + # GitHub App authentication + - GITHUB_APP_ID=${GITHUB_APP_ID} + - GITHUB_APP_INSTALLATION_ID=${GITHUB_APP_INSTALLATION_ID} + + # Private key (choose one method) + - GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY:-} + - GITHUB_APP_PRIVATE_KEY_BASE64=${GITHUB_APP_PRIVATE_KEY_BASE64:-} + + # Runner configuration + - RUNNER_NAME=unity-ci-linux-il2cpp + - RUNNER_LABELS=self-hosted,linux,unity,docker,linux-il2cpp + + # Unity Accelerator 接続設定 + - UNITY_ACCELERATOR_ENDPOINT=unity-accelerator:10080 + + volumes: + # Unity license file + - ${UNITY_LICENSE_PATH:-/var/lib/unity-license}:/unity-license:ro + + # Project Library cache (runner 別に分離: Win64/Linux64 で Library 内容が異なるため) + - unity-library-cache-linux-il2cpp:/home/runner/.unity-library-cache + + # Unity global cache + - unity-global-cache-linux-il2cpp:/home/runner/.local/share/unity3d + + deploy: + resources: + limits: + cpus: '4' + memory: 12G + reservations: + cpus: '2' + memory: 8G + + security_opt: + - no-new-privileges:false + + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "3" + + networks: + - unity-ci-network + # Accelerator と同じネットワークを使用(外部定義) networks: unity-ci-network: @@ -97,7 +166,13 @@ networks: # Named Volumes(WSL2内に保存、docker compose down -v で削除) volumes: + # unity-ci-windows-mono 用(既存名維持) unity-library-cache: name: unity-library-cache unity-global-cache: name: unity-global-cache + # unity-ci-linux-il2cpp 用(新規) + unity-library-cache-linux-il2cpp: + name: unity-library-cache-linux-il2cpp + unity-global-cache-linux-il2cpp: + name: unity-global-cache-linux-il2cpp diff --git a/docker/unity-server/README.md b/docker/unity-server/README.md new file mode 100644 index 000000000..615cf40a5 --- /dev/null +++ b/docker/unity-server/README.md @@ -0,0 +1,89 @@ +# Unity Dedicated Server デプロイ + +Unity 6 + Photon Fusion 2 の Dedicated Server を GCE (Container-Optimized OS) + Managed Instance Group (MIG) にデプロイする。Cloud Run は UDP 非対応のため使用不可。 + +## ファイル構成 + +| ファイル | 用途 | +|---------|------| +| `Dockerfile` | Linux Server ビルド出力をコンテナ化する Dockerfile | +| `prod/deploy.ps1` | Windows (PowerShell) からの本番デプロイスクリプト | +| `prod/deploy.sh` | Linux/macOS (bash) からの本番デプロイスクリプト | +| `prod/cloudbuild.yml` | Cloud Build からの自動デプロイ設定 | +| `prod/docker-compose.yml` | ローカル動作確認用 | +| `prod/.env.example` | 本番デプロイ用環境変数テンプレート | + +## デプロイの流れ + +1. Unity Editor で Linux Dedicated Server をビルド (`src/Game.Client/Builds/Server/Linux/`) +2. `prod/.env` を `.env.example` から作成し、PROJECT_ID 等を設定 +3. (初回のみ) `./deploy.ps1 -SetupInfra` または `./deploy.sh --setup-infra` で firewall / health check / Secret IAM を作成 +4. `./deploy.ps1` または `./deploy.sh` で build → push → Template 作成 → MIG ローリング更新 + +## デプロイスクリプトのオプション + +``` +-BuildOnly / --build-only build + push のみ(deploy しない) +-SkipBuild / --skip-build build をスキップ(既存 image で deploy) +-ImageTag / --image-tag TAG Artifact Registry image tag (default: latest) +-TemplateSuffix / --template-suffix Instance Template の name suffix + 省略時は "{ImageTag}-{UnixTime}" を自動付与 + (再実行で alreadyExists 衝突回避 + ロールバック互換性) +-InitialDelay / --initial-delay autohealing initial-delay 秒 (default: 180) +-Force / --force rolling-action 進行中でも実行 + (接続中ユーザーは強制切断) +-SetupInfra / --setup-infra GCE インフラ初期セットアップ +-Tag / --tag DEPRECATED: --image-tag と --template-suffix の両方に適用 + 次期メジャー版で削除予定 +``` + +## 既知の制約 / 運用上の注意 + +### Photon Fusion DS のセッション切断 + +`deploy.ps1` / `deploy.sh` / `cloudbuild.yml` は `--minimal-action=replace` を使用するため、 +**ローリング更新中に接続中の全プレイヤーが切断**される。本番運用ではプレイヤーがいない +時間帯(メンテナンス枠)で実行するか、将来 drain mode(新規セッション受付停止 + 既存 +セッション自然終了待ち)を実装することを検討する。 + +### CI/CD で必要な IAM 権限 + +`gcloud artifacts docker images describe`(image manifest 事前検証)を呼ぶため、 +CI service account には `roles/artifactregistry.reader` が必要。 + +その他必要な権限: + +| ロール | 用途 | +|-------|------| +| `roles/compute.instanceAdmin.v1` | Instance Template + MIG 操作 | +| `roles/secretmanager.secretAccessor` | UNITY_SERVER_AUTH_SESSION_SECRET 取得 | +| `roles/iam.serviceAccountUser` | GCE デフォルト SA への impersonation | +| `roles/artifactregistry.reader` | image manifest 検証 | +| `roles/artifactregistry.writer` | image push(Cloud Build SA で必要) | + +### autohealing initial-delay + +Unity DS image (~1-2GB) 初回 pull + Mono/IL2CPP 初期化 + Fusion StartServerAsync で +合計 120-180s が現実値。デフォルト `--initial-delay=180` で運用。 +image サイズ増加時は `-InitialDelay 240` 等で延長すること。 + +### Instance Template の自動 GC + +`-TemplateSuffix` 省略時は UnixTime suffix 付きで毎回新規 Template が作成される。 +**MIG が参照していない古い Template は手動で削除する**: + +```bash +# 30 日前以前の template を確認 +gcloud compute instance-templates list \ + --filter='name~unity-server-template AND creationTimestamp<-P30D' \ + --sort-by=creationTimestamp + +# 削除 +gcloud compute instance-templates delete --quiet +``` + +### rolling-action 二重実行防御 + +`./deploy.ps1` / `./deploy.sh` は MIG の `status.versionTarget.isReached` を確認し、 +進行中の rolling-action がある場合は `-Force` / `--force` なしで中止する。 +緊急時のみ `-Force` で上書き可能(接続中ユーザーが切断される)。 diff --git a/docker/unity-server/prod/cloudbuild.yml b/docker/unity-server/prod/cloudbuild.yml index b100620bf..5989bb1d2 100644 --- a/docker/unity-server/prod/cloudbuild.yml +++ b/docker/unity-server/prod/cloudbuild.yml @@ -87,13 +87,22 @@ steps: --zone=${_ZONE} \ --template="$${TEMPLATE_NAME}" + # autohealing initial-delay を 180 秒に更新 + # (初回 docker pull + Unity DS 起動を待つ猶予。失敗しても致命でない) + gcloud compute instance-groups managed update \ + ${_INSTANCE_GROUP_NAME} \ + --zone=${_ZONE} \ + --initial-delay=${_INITIAL_DELAY} || true + # ローリングアップデートを開始 + # --max-unavailable=0 は minimalAction=RESTART と衝突するため --minimal-action=replace を明示 gcloud compute instance-groups managed rolling-action start-update \ ${_INSTANCE_GROUP_NAME} \ --zone=${_ZONE} \ --version="template=$${TEMPLATE_NAME}" \ --max-surge=1 \ - --max-unavailable=0 + --max-unavailable=0 \ + --minimal-action=replace else echo "Creating new MIG..." gcloud compute instance-groups managed create ${_INSTANCE_GROUP_NAME} \ @@ -101,7 +110,7 @@ steps: --template="$${TEMPLATE_NAME}" \ --size=1 \ --health-check=${_HEALTH_CHECK_NAME} \ - --initial-delay=60 + --initial-delay=${_INITIAL_DELAY} fi # ===== 変数(デフォルト値) ===== @@ -116,6 +125,8 @@ substitutions: _UNITY_SERVER_PORT: '7777' _UNITY_SERVER_HEALTH_PORT: '7778' _HEALTH_CHECK_NAME: unity-server-health-check + # autohealing initial-delay 秒(image pull + Unity DS 起動の猶予、実測 120-180s) + _INITIAL_DELAY: '180' # Unity Server ビルド成果物のパス(Cloud Build ワークスペース内) _BUILD_CONTEXT: 'server-build/' diff --git a/docker/unity-server/prod/deploy.ps1 b/docker/unity-server/prod/deploy.ps1 index 9bbc9e922..3de42c113 100644 --- a/docker/unity-server/prod/deploy.ps1 +++ b/docker/unity-server/prod/deploy.ps1 @@ -1,4 +1,4 @@ -# docker/unity-server/prod/deploy.ps1 +# docker/unity-server/prod/deploy.ps1 # Unity Dedicated Server GCE deployment script (PowerShell) # # Usage: @@ -9,22 +9,51 @@ # Deploys to GCE with Container-Optimized OS # # Options: -# -BuildOnly Build + push only (no deploy) -# -SkipBuild Skip build (deploy with existing image) -# -Tag TAG Image tag (default: latest) -# -SetupInfra First-time GCE infrastructure setup (firewall, health check) +# -BuildOnly Build + push only (no deploy) +# -SkipBuild Skip build (deploy with existing image) +# -ImageTag TAG Artifact Registry image tag (default: latest) +# -TemplateSuffix SUFFIX Instance-template name suffix +# If empty, "{ImageTag}-{UnixTime}" is auto-generated +# so re-runs do not collide on alreadyExists. +# -InitialDelay SECONDS Autohealing initial-delay (default: 180) +# Allows time for first docker pull + Unity DS startup. +# -Force Override rolling-action-in-progress check +# (will disconnect active sessions) +# -SetupInfra First-time GCE infrastructure setup (firewall, health check) +# -Tag TAG DEPRECATED: equivalent to -ImageTag and -TemplateSuffix +# Will be removed in next major version. +# +# Environment file (.env) variable INITIAL_DELAY overrides default 180 if set. param( [switch]$BuildOnly, [switch]$SkipBuild, - [string]$Tag = "latest", - [switch]$SetupInfra + [string]$ImageTag = "latest", + [string]$TemplateSuffix = "", + [int]$InitialDelay = 180, + [switch]$Force, + [switch]$SetupInfra, + [string]$Tag = "" ) -$ErrorActionPreference = "Stop" +# gcloud は warning を stderr に出して exit 0 で返すため、Stop だと致命扱いされて誤中断する。 +# Continue にして $LASTEXITCODE を個別判定する方針。 +$ErrorActionPreference = "Continue" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ProjectRoot = Resolve-Path "$ScriptDir\..\..\..\" +# Backward-compat: -Tag を ImageTag/TemplateSuffix の両方に適用 +if ($Tag) { + if (-not $PSBoundParameters.ContainsKey('ImageTag')) { $ImageTag = $Tag } + if (-not $PSBoundParameters.ContainsKey('TemplateSuffix')) { $TemplateSuffix = $Tag } + Write-Host "[DEPRECATED] -Tag is deprecated, will be removed in next major version. Use -ImageTag and -TemplateSuffix." -ForegroundColor Yellow +} + +# TemplateSuffix が空なら UnixTime を自動付与 → 同 ImageTag での再実行衝突回避 + ロールバック互換性 +if (-not $TemplateSuffix) { + $TemplateSuffix = "$ImageTag-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" +} + # Load .env file $EnvFile = "$ScriptDir\.env" if (Test-Path $EnvFile) { @@ -43,6 +72,11 @@ if (Test-Path $EnvFile) { exit 1 } +# .env で INITIAL_DELAY が指定されていれば上書き(ただし -InitialDelay の明示指定が優先) +if ($env:INITIAL_DELAY -and -not $PSBoundParameters.ContainsKey('InitialDelay')) { + $InitialDelay = [int]$env:INITIAL_DELAY +} + # Check required variables $RequiredVars = @("PROJECT_ID", "REGION", "ZONE", "REPO_NAME", "INSTANCE_GROUP_NAME", "INSTANCE_TEMPLATE_NAME", "GAME_SERVER_URL", "SECRET_UNITY_SERVER_AUTH") foreach ($var in $RequiredVars) { @@ -61,13 +95,16 @@ $HealthCheckName = if ($env:HEALTH_CHECK_NAME) { $env:HEALTH_CHECK_NAME } else { $IMAGE = "$env:REGION-docker.pkg.dev/$env:PROJECT_ID/$env:REPO_NAME/unity-server" $BuildContext = "$ProjectRoot\src\Game.Client\Builds\Server\Linux" +$TemplateName = "$env:INSTANCE_TEMPLATE_NAME-$TemplateSuffix" Write-Host "" Write-Host "===== Deploy Configuration (Unity Server -> GCE) =====" -ForegroundColor Cyan Write-Host "PROJECT_ID: $env:PROJECT_ID" Write-Host "REGION: $env:REGION" Write-Host "ZONE: $env:ZONE" -Write-Host "IMAGE: ${IMAGE}:${Tag}" +Write-Host "IMAGE: ${IMAGE}:${ImageTag}" +Write-Host "TEMPLATE_NAME: $TemplateName" +Write-Host "INITIAL_DELAY: $InitialDelay seconds" Write-Host "MACHINE_TYPE: $MachineType" Write-Host "UNITY_SERVER_PORT: $UnityServerPort (UDP)" Write-Host "UNITY_SERVER_HEALTH_PORT: $UnityServerHealthPort (TCP)" @@ -79,6 +116,19 @@ Write-Host "SECRET_NAME: $env:SECRET_UNITY_SERVER_AUTH" Write-Host "=======================================================" -ForegroundColor Cyan Write-Host "" +# 失敗時に gcloud の stderr を保全して表示するヘルパ。 +# Description: ログ表示用の操作名。Command: gcloud 呼出を含む scriptblock。 +function Invoke-Gcloud { + param([string]$Description, [scriptblock]$Command) + $captured = & $Command 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Write-Host "[ERROR] $Description failed (exit=$LASTEXITCODE)" -ForegroundColor Red + Write-Host $captured -ForegroundColor DarkGray + exit $LASTEXITCODE + } + return $captured +} + # Infrastructure setup (first time only) if ($SetupInfra) { Write-Host "[SETUP] Creating GCE infrastructure..." -ForegroundColor Yellow @@ -94,7 +144,7 @@ if ($SetupInfra) { --allow="udp:$UnityServerPort" ` --target-tags=$NetworkTag ` --description="Allow UDP game traffic to Unity Server" ` - --quiet 2>$null + --quiet 2>&1 | Out-Null # Ignore error if already exists # Firewall rule: TCP (health check) @@ -106,7 +156,20 @@ if ($SetupInfra) { --source-ranges="35.191.0.0/16,130.211.0.0/22" ` --target-tags=$NetworkTag ` --description="Allow GCE health check to Unity Server" ` - --quiet 2>$null + --quiet 2>&1 | Out-Null + + # Firewall rule: TCP (Cloud Run Direct VPC Egress → DS internal communication) + # Game.Server が Direct VPC Egress 経由で DS の /session/start に HTTP POST を送信するために必要 + $FwRuleInternal = if ($env:FIREWALL_RULE_INTERNAL) { $env:FIREWALL_RULE_INTERNAL } else { "allow-unity-server-internal" } + $VpcConnectorSubnet = if ($env:VPC_CONNECTOR_SUBNET) { $env:VPC_CONNECTOR_SUBNET } else { "10.10.0.0/26" } + Write-Host "[SETUP] Creating firewall rule for internal traffic (TCP $UnityServerHealthPort from Direct VPC Egress $VpcConnectorSubnet)..." -ForegroundColor Yellow + gcloud compute firewall-rules create $FwRuleInternal ` + --network=$Network ` + --allow="tcp:$UnityServerHealthPort" ` + --source-ranges="$VpcConnectorSubnet" ` + --target-tags=$NetworkTag ` + --description="Allow Cloud Run Direct VPC Egress to send session/start to Unity Server" ` + --quiet 2>&1 | Out-Null # TCP health check Write-Host "[SETUP] Creating TCP health check..." -ForegroundColor Yellow @@ -116,7 +179,7 @@ if ($SetupInfra) { --timeout=5s ` --healthy-threshold=2 ` --unhealthy-threshold=3 ` - --quiet 2>$null + --quiet 2>&1 | Out-Null # Secret Manager IAM: Grant secretAccessor to GCE default service account Write-Host "[SETUP] Granting Secret Manager access to GCE default service account..." -ForegroundColor Yellow @@ -125,8 +188,7 @@ if ($SetupInfra) { --member="serviceAccount:$DefaultSa" ` --role="roles/secretmanager.secretAccessor" ` --project=$env:PROJECT_ID ` - --quiet 2>$null - # Ignore error if binding already exists + --quiet 2>&1 | Out-Null Write-Host "[SETUP] Infrastructure setup complete" -ForegroundColor Green Write-Host "" @@ -148,14 +210,14 @@ if (-not $SkipBuild) { # Docker build Write-Host "[2/5] Building Docker image..." -ForegroundColor Yellow - docker build -t "${IMAGE}:${Tag}" ` + docker build -t "${IMAGE}:${ImageTag}" ` -f "$ProjectRoot\docker\unity-server\Dockerfile" ` "$BuildContext" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Push to registry Write-Host "[3/5] Pushing to Artifact Registry..." -ForegroundColor Yellow - docker push "${IMAGE}:${Tag}" + docker push "${IMAGE}:${ImageTag}" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } else { Write-Host "[2/5] Skipping build..." -ForegroundColor Gray @@ -163,6 +225,19 @@ if (-not $SkipBuild) { } if (-not $BuildOnly) { + # Verify image manifest exists in registry before proceeding (fail-fast) + # Requires CI service account to have roles/artifactregistry.reader + Write-Host "[CHECK] Verifying image exists in Artifact Registry..." -ForegroundColor Yellow + $null = gcloud artifacts docker images describe "${IMAGE}:${ImageTag}" ` + --format="value(image_summary.digest)" 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host "[ERROR] Image not found: ${IMAGE}:${ImageTag}" -ForegroundColor Red + Write-Host "[HINT] Run without -SkipBuild to build & push first," -ForegroundColor Yellow + Write-Host " or specify an existing -ImageTag." -ForegroundColor Yellow + Write-Host "[HINT] CI service account requires roles/artifactregistry.reader." -ForegroundColor Yellow + exit 1 + } + # Fetch HMAC secret from Secret Manager Write-Host "[INFO] Fetching secret from Secret Manager ($env:SECRET_UNITY_SERVER_AUTH)..." -ForegroundColor Yellow $UnitySecret = gcloud secrets versions access latest ` @@ -174,53 +249,76 @@ if (-not $BuildOnly) { } # Create instance template - Write-Host "[4/5] Creating instance template..." -ForegroundColor Yellow - $TemplateName = "$env:INSTANCE_TEMPLATE_NAME-$Tag" - - gcloud compute instance-templates create-with-container $TemplateName ` - --machine-type=$MachineType ` - --tags=$NetworkTag ` - --container-image="${IMAGE}:${Tag}" ` - --container-env="UNITY_SERVER_AUTH_SESSION_SECRET=$UnitySecret" ` - --container-env="GAME_SERVER_URL=$env:GAME_SERVER_URL" ` - --container-env="UNITY_SERVER_PORT=$UnityServerPort" ` - --container-env="UNITY_SERVER_HEALTH_PORT=$UnityServerHealthPort" ` - --container-arg="--port" ` - --container-arg=$UnityServerPort ` - --container-arg="--health-port" ` - --container-arg=$UnityServerHealthPort ` - --scopes=https://www.googleapis.com/auth/cloud-platform ` - --region=$env:REGION ` - --quiet 2>$null + Write-Host "[4/5] Creating instance template $TemplateName..." -ForegroundColor Yellow + + Invoke-Gcloud "Create instance template" { + gcloud compute instance-templates create-with-container $TemplateName ` + --machine-type=$MachineType ` + --tags=$NetworkTag ` + --container-image="${IMAGE}:${ImageTag}" ` + --container-env="UNITY_SERVER_AUTH_SESSION_SECRET=$UnitySecret" ` + --container-env="GAME_SERVER_URL=$env:GAME_SERVER_URL" ` + --container-env="UNITY_SERVER_PORT=$UnityServerPort" ` + --container-env="UNITY_SERVER_HEALTH_PORT=$UnityServerHealthPort" ` + --container-arg="--port" ` + --container-arg=$UnityServerPort ` + --container-arg="--health-port" ` + --container-arg=$UnityServerHealthPort ` + --scopes=https://www.googleapis.com/auth/cloud-platform ` + --region=$env:REGION + } | Out-Null # Update or create MIG Write-Host "[5/5] Updating Managed Instance Group..." -ForegroundColor Yellow + + # MIG 存在判定(失敗が想定内なので 2>$null は維持) $MigExists = gcloud compute instance-groups managed describe $env:INSTANCE_GROUP_NAME ` --zone=$env:ZONE 2>$null + if ($MigExists) { - # Update existing MIG - gcloud compute instance-groups managed set-instance-template ` - $env:INSTANCE_GROUP_NAME ` - --zone=$env:ZONE ` - --template=$TemplateName - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # 進行中の rolling-action がある場合は -Force なしで中止(接続中ユーザー保護) + $isReached = gcloud compute instance-groups managed describe ` + $env:INSTANCE_GROUP_NAME --zone=$env:ZONE ` + --format="value(status.versionTarget.isReached)" 2>$null + if ($isReached -eq "False" -and -not $Force) { + Write-Host "[ERROR] Rolling update is already in progress." -ForegroundColor Red + Write-Host "[HINT] Wait for completion, or use -Force to override (will disconnect active sessions)." -ForegroundColor Yellow + exit 1 + } - gcloud compute instance-groups managed rolling-action start-update ` + Invoke-Gcloud "Set instance template" { + gcloud compute instance-groups managed set-instance-template ` + $env:INSTANCE_GROUP_NAME ` + --zone=$env:ZONE ` + --template=$TemplateName + } | Out-Null + + # autohealing initial-delay の更新は致命でない(template 切替成功すれば deploy 全体は成功扱い) + gcloud compute instance-groups managed update ` $env:INSTANCE_GROUP_NAME ` --zone=$env:ZONE ` - --version="template=$TemplateName" ` - --max-surge=1 ` - --max-unavailable=0 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + --initial-delay=$InitialDelay 2>&1 | Out-Null + + # rolling-action: --max-unavailable=0 と RESTART の衝突を回避するため --minimal-action=replace を明示 + Invoke-Gcloud "Start rolling update" { + gcloud compute instance-groups managed rolling-action start-update ` + $env:INSTANCE_GROUP_NAME ` + --zone=$env:ZONE ` + --version="template=$TemplateName" ` + --max-surge=1 ` + --max-unavailable=0 ` + --minimal-action=replace + } | Out-Null } else { # Create new MIG - gcloud compute instance-groups managed create $env:INSTANCE_GROUP_NAME ` - --zone=$env:ZONE ` - --template=$TemplateName ` - --size=1 ` - --health-check=$HealthCheckName ` - --initial-delay=60 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Invoke-Gcloud "Create new MIG" { + gcloud compute instance-groups managed create $env:INSTANCE_GROUP_NAME ` + --zone=$env:ZONE ` + --template=$TemplateName ` + --size=1 ` + --health-check=$HealthCheckName ` + --initial-delay=$InitialDelay + } | Out-Null } Write-Host "" @@ -235,5 +333,5 @@ if (-not $BuildOnly) { Write-Host "[5/5] Skipping deploy (BuildOnly mode)..." -ForegroundColor Gray Write-Host "" Write-Host "===== Build Complete =====" -ForegroundColor Green - Write-Host "Image: ${IMAGE}:${Tag}" -ForegroundColor Cyan + Write-Host "Image: ${IMAGE}:${ImageTag}" -ForegroundColor Cyan } diff --git a/docker/unity-server/prod/deploy.sh b/docker/unity-server/prod/deploy.sh index bedbee36a..1215a05f5 100644 --- a/docker/unity-server/prod/deploy.sh +++ b/docker/unity-server/prod/deploy.sh @@ -11,32 +11,71 @@ # Unity Server は UDP を使用するため Cloud Run は使用不可 # # オプション: -# --build-only ビルド+プッシュのみ(デプロイしない) -# --skip-build ビルドをスキップ(既存イメージでデプロイ) -# --tag TAG イメージタグ指定(デフォルト: latest) -# --setup-infra GCE インフラ初期セットアップ(ファイアウォール、ヘルスチェック) +# --build-only ビルド+プッシュのみ(デプロイしない) +# --skip-build ビルドをスキップ(既存イメージでデプロイ) +# --image-tag TAG Artifact Registry の image tag(デフォルト: latest) +# --template-suffix SUFFIX instance-template 名サフィックス +# 空なら "{ImageTag}-{UnixTime}" を自動付与し、 +# 再実行で alreadyExists 衝突を回避する。 +# --initial-delay SECONDS autohealing initial-delay(デフォルト: 180 秒) +# 初回 docker pull + Unity DS 起動を待つための猶予。 +# --force rolling-action 進行中でも実行(接続中ユーザーは切断される) +# --setup-infra GCE インフラ初期セットアップ(ファイアウォール、ヘルスチェック) +# --tag TAG DEPRECATED: --image-tag と --template-suffix の両方に適用される +# 次期メジャー版で削除予定。 +# +# .env の INITIAL_DELAY が設定されている場合、デフォルト 180 を上書きする。 -set -e +# gcloud は warning を stderr に出して exit 0 で返すため、set -e ではなく +# 各コマンドの $? を個別判定する方針。 +set +e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" BUILD_ONLY=false SKIP_BUILD=false -TAG="latest" +IMAGE_TAG="latest" +TEMPLATE_SUFFIX="" +INITIAL_DELAY=180 +FORCE=false SETUP_INFRA=false +LEGACY_TAG="" +IMAGE_TAG_EXPLICIT=false +TEMPLATE_SUFFIX_EXPLICIT=false +INITIAL_DELAY_EXPLICIT=false # 引数解析 while [[ $# -gt 0 ]]; do case $1 in --build-only) BUILD_ONLY=true; shift ;; --skip-build) SKIP_BUILD=true; shift ;; - --tag) TAG="$2"; shift 2 ;; + --image-tag) IMAGE_TAG="$2"; IMAGE_TAG_EXPLICIT=true; shift 2 ;; + --template-suffix) TEMPLATE_SUFFIX="$2"; TEMPLATE_SUFFIX_EXPLICIT=true; shift 2 ;; + --initial-delay) INITIAL_DELAY="$2"; INITIAL_DELAY_EXPLICIT=true; shift 2 ;; + --force) FORCE=true; shift ;; --setup-infra) SETUP_INFRA=true; shift ;; + --tag) LEGACY_TAG="$2"; shift 2 ;; *) echo "Unknown option: $1"; exit 1 ;; esac done +# Backward-compat: --tag を image-tag/template-suffix の両方に適用 +if [[ -n "$LEGACY_TAG" ]]; then + if [[ "$IMAGE_TAG_EXPLICIT" == "false" ]]; then IMAGE_TAG="$LEGACY_TAG"; fi + if [[ "$TEMPLATE_SUFFIX_EXPLICIT" == "false" ]]; then TEMPLATE_SUFFIX="$LEGACY_TAG"; fi + echo "[DEPRECATED] --tag は廃止予定です。--image-tag と --template-suffix を使ってください(次期メジャー版で削除)" +fi + +# TEMPLATE_SUFFIX が空なら UnixTime を自動付与(再実行衝突回避 + ロールバック互換性) +if [[ -z "$TEMPLATE_SUFFIX" ]]; then + TEMPLATE_SUFFIX="${IMAGE_TAG}-$(date +%s)" +fi + +# .env 読み込みで INITIAL_DELAY が上書きされる前に args 値を退避 +# 優先順位: 引数明示 > .env > スクリプトデフォルト(180) +ARG_INITIAL_DELAY="$INITIAL_DELAY" + # .env ファイルを読み込み ENV_FILE="$SCRIPT_DIR/.env" if [[ -f "$ENV_FILE" ]]; then @@ -49,6 +88,12 @@ else exit 1 fi +# 引数明示なら args 値で復元(.env が上書きしていても args が勝つ) +# 引数なしなら .env の INITIAL_DELAY、それも無ければスクリプトデフォルト 180 が残る +if [[ "$INITIAL_DELAY_EXPLICIT" == "true" ]]; then + INITIAL_DELAY="$ARG_INITIAL_DELAY" +fi + # 必須変数の確認 REQUIRED_VARS=("PROJECT_ID" "REGION" "ZONE" "REPO_NAME" "INSTANCE_GROUP_NAME" "INSTANCE_TEMPLATE_NAME" "GAME_SERVER_URL" "SECRET_UNITY_SERVER_AUTH") for var in "${REQUIRED_VARS[@]}"; do @@ -66,6 +111,7 @@ NETWORK_TAG="${NETWORK_TAG:-unity-server}" HEALTH_CHECK_NAME="${HEALTH_CHECK_NAME:-unity-server-health-check}" IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/unity-server" +TEMPLATE_NAME="${INSTANCE_TEMPLATE_NAME}-${TEMPLATE_SUFFIX}" # Unity Server ビルド出力パス BUILD_CONTEXT="${PROJECT_ROOT}/src/Game.Client/Builds/Server/Linux" @@ -75,7 +121,9 @@ echo "===== Deploy Configuration (Unity Server -> GCE) =====" echo "PROJECT_ID: $PROJECT_ID" echo "REGION: $REGION" echo "ZONE: $ZONE" -echo "IMAGE: ${IMAGE}:${TAG}" +echo "IMAGE: ${IMAGE}:${IMAGE_TAG}" +echo "TEMPLATE_NAME: $TEMPLATE_NAME" +echo "INITIAL_DELAY: $INITIAL_DELAY seconds" echo "MACHINE_TYPE: $MACHINE_TYPE" echo "UNITY_SERVER_PORT: $UNITY_SERVER_PORT (UDP)" echo "UNITY_SERVER_HEALTH_PORT: $UNITY_SERVER_HEALTH_PORT (TCP)" @@ -87,6 +135,23 @@ echo "SECRET_NAME: $SECRET_UNITY_SERVER_AUTH" echo "=======================================================" echo "" +# 失敗時に gcloud の stderr を保全して表示するヘルパ。 +# $1: 操作名(ログ表示用) +# $2..: 実行する gcloud コマンド +invoke_gcloud() { + local description="$1" + shift + local captured + captured="$("$@" 2>&1)" + local rc=$? + if [[ $rc -ne 0 ]]; then + echo "[ERROR] $description failed (exit=$rc)" >&2 + echo "$captured" >&2 + exit $rc + fi + echo "$captured" +} + # インフラセットアップ(初回のみ) if [[ "$SETUP_INFRA" == "true" ]]; then echo "[SETUP] Creating GCE infrastructure..." @@ -101,7 +166,6 @@ if [[ "$SETUP_INFRA" == "true" ]]; then --quiet 2>/dev/null || echo " (firewall rule already exists)" # ファイアウォールルール: TCP (ヘルスチェック) - # GCE ヘルスチェックのソース IP レンジ: 35.191.0.0/16, 130.211.0.0/22 echo "[SETUP] Creating firewall rule for health check (TCP ${UNITY_SERVER_HEALTH_PORT})..." gcloud compute firewall-rules create "${FIREWALL_RULE_HEALTH:-allow-unity-server-health}" \ --network="${NETWORK:-default}" \ @@ -111,6 +175,17 @@ if [[ "$SETUP_INFRA" == "true" ]]; then --description="Allow GCE health check to Unity Server" \ --quiet 2>/dev/null || echo " (firewall rule already exists)" + # ファイアウォールルール: TCP (Cloud Run Direct VPC Egress → DS 内部通信) + VPC_CONNECTOR_SUBNET="${VPC_CONNECTOR_SUBNET:-10.10.0.0/26}" + echo "[SETUP] Creating firewall rule for internal traffic (TCP ${UNITY_SERVER_HEALTH_PORT} from Direct VPC Egress ${VPC_CONNECTOR_SUBNET})..." + gcloud compute firewall-rules create "${FIREWALL_RULE_INTERNAL:-allow-unity-server-internal}" \ + --network="${NETWORK:-default}" \ + --allow="tcp:${UNITY_SERVER_HEALTH_PORT}" \ + --source-ranges="${VPC_CONNECTOR_SUBNET}" \ + --target-tags="${NETWORK_TAG}" \ + --description="Allow Cloud Run Direct VPC Egress to send session/start to Unity Server" \ + --quiet 2>/dev/null || echo " (firewall rule already exists)" + # TCP ヘルスチェック作成 echo "[SETUP] Creating TCP health check..." gcloud compute health-checks create tcp "${HEALTH_CHECK_NAME}" \ @@ -121,7 +196,7 @@ if [[ "$SETUP_INFRA" == "true" ]]; then --unhealthy-threshold=3 \ --quiet 2>/dev/null || echo " (health check already exists)" - # Secret Manager IAM: GCE デフォルトサービスアカウントに secretAccessor を付与 + # Secret Manager IAM echo "[SETUP] Granting Secret Manager access to GCE default service account..." gcloud secrets add-iam-policy-binding "${SECRET_UNITY_SERVER_AUTH}" \ --member="serviceAccount:$(gcloud compute project-info describe --format='value(defaultServiceAccount)')" \ @@ -136,9 +211,9 @@ fi # Docker 認証 echo "[1/5] Configuring Docker authentication..." gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet +if [[ $? -ne 0 ]]; then exit $?; fi if [[ "$SKIP_BUILD" != "true" ]]; then - # ビルドコンテキストの確認 if [[ ! -d "$BUILD_CONTEXT" ]]; then echo "[ERROR] Unity Server build output not found: $BUILD_CONTEXT" echo "[ERROR] Build the Unity Dedicated Server first:" @@ -146,21 +221,34 @@ if [[ "$SKIP_BUILD" != "true" ]]; then exit 1 fi - # Docker ビルド echo "[2/5] Building Docker image..." - docker build -t "${IMAGE}:${TAG}" \ + docker build -t "${IMAGE}:${IMAGE_TAG}" \ -f "${PROJECT_ROOT}/docker/unity-server/Dockerfile" \ "$BUILD_CONTEXT" + if [[ $? -ne 0 ]]; then exit $?; fi - # プッシュ echo "[3/5] Pushing to Artifact Registry..." - docker push "${IMAGE}:${TAG}" + docker push "${IMAGE}:${IMAGE_TAG}" + if [[ $? -ne 0 ]]; then exit $?; fi else echo "[2/5] Skipping build..." echo "[3/5] Skipping push..." fi if [[ "$BUILD_ONLY" != "true" ]]; then + # image manifest 事前検証(fail-fast) + # CI service account に roles/artifactregistry.reader が必要 + echo "[CHECK] Verifying image exists in Artifact Registry..." + gcloud artifacts docker images describe "${IMAGE}:${IMAGE_TAG}" \ + --format="value(image_summary.digest)" >/dev/null 2>&1 + if [[ $? -ne 0 ]]; then + echo "[ERROR] Image not found: ${IMAGE}:${IMAGE_TAG}" + echo "[HINT] Run without --skip-build to build & push first," + echo " or specify an existing --image-tag." + echo "[HINT] CI service account requires roles/artifactregistry.reader." + exit 1 + fi + # Secret Manager から HMAC シークレット取得 echo "[INFO] Fetching secret from Secret Manager (${SECRET_UNITY_SERVER_AUTH})..." UNITY_SERVER_AUTH_SESSION_SECRET=$(gcloud secrets versions access latest \ @@ -172,13 +260,12 @@ if [[ "$BUILD_ONLY" != "true" ]]; then fi # インスタンステンプレート作成 - echo "[4/5] Creating instance template..." - TEMPLATE_NAME="${INSTANCE_TEMPLATE_NAME}-${TAG}" - - gcloud compute instance-templates create-with-container "${TEMPLATE_NAME}" \ + echo "[4/5] Creating instance template ${TEMPLATE_NAME}..." + invoke_gcloud "Create instance template" \ + gcloud compute instance-templates create-with-container "${TEMPLATE_NAME}" \ --machine-type="${MACHINE_TYPE}" \ --tags="${NETWORK_TAG}" \ - --container-image="${IMAGE}:${TAG}" \ + --container-image="${IMAGE}:${IMAGE_TAG}" \ --container-env="UNITY_SERVER_AUTH_SESSION_SECRET=${UNITY_SERVER_AUTH_SESSION_SECRET}" \ --container-env="GAME_SERVER_URL=${GAME_SERVER_URL}" \ --container-env="UNITY_SERVER_PORT=${UNITY_SERVER_PORT}" \ @@ -188,34 +275,52 @@ if [[ "$BUILD_ONLY" != "true" ]]; then --container-arg="--health-port" \ --container-arg="${UNITY_SERVER_HEALTH_PORT}" \ --scopes=https://www.googleapis.com/auth/cloud-platform \ - --region="${REGION}" \ - --quiet 2>/dev/null \ - || echo " (template may already exist, continuing...)" + --region="${REGION}" >/dev/null # MIG 更新 or 作成 echo "[5/5] Updating Managed Instance Group..." if gcloud compute instance-groups managed describe "${INSTANCE_GROUP_NAME}" \ - --zone="${ZONE}" 2>/dev/null; then - # 既存 MIG を更新 - gcloud compute instance-groups managed set-instance-template \ + --zone="${ZONE}" >/dev/null 2>&1; then + # 進行中の rolling-action がある場合は --force なしで中止 + IS_REACHED=$(gcloud compute instance-groups managed describe \ + "${INSTANCE_GROUP_NAME}" --zone="${ZONE}" \ + --format="value(status.versionTarget.isReached)" 2>/dev/null) + if [[ "$IS_REACHED" == "False" && "$FORCE" != "true" ]]; then + echo "[ERROR] Rolling update is already in progress." + echo "[HINT] Wait for completion, or use --force to override (will disconnect active sessions)." + exit 1 + fi + + invoke_gcloud "Set instance template" \ + gcloud compute instance-groups managed set-instance-template \ + "${INSTANCE_GROUP_NAME}" \ + --zone="${ZONE}" \ + --template="${TEMPLATE_NAME}" >/dev/null + + # autohealing initial-delay 更新(致命でない) + gcloud compute instance-groups managed update \ "${INSTANCE_GROUP_NAME}" \ --zone="${ZONE}" \ - --template="${TEMPLATE_NAME}" + --initial-delay="${INITIAL_DELAY}" >/dev/null 2>&1 || true - gcloud compute instance-groups managed rolling-action start-update \ + # rolling-action: --max-unavailable=0 と RESTART の衝突回避のため --minimal-action=replace を明示 + invoke_gcloud "Start rolling update" \ + gcloud compute instance-groups managed rolling-action start-update \ "${INSTANCE_GROUP_NAME}" \ --zone="${ZONE}" \ --version="template=${TEMPLATE_NAME}" \ --max-surge=1 \ - --max-unavailable=0 + --max-unavailable=0 \ + --minimal-action=replace >/dev/null else # 新規 MIG を作成 - gcloud compute instance-groups managed create "${INSTANCE_GROUP_NAME}" \ + invoke_gcloud "Create new MIG" \ + gcloud compute instance-groups managed create "${INSTANCE_GROUP_NAME}" \ --zone="${ZONE}" \ --template="${TEMPLATE_NAME}" \ --size=1 \ --health-check="${HEALTH_CHECK_NAME}" \ - --initial-delay=60 + --initial-delay="${INITIAL_DELAY}" >/dev/null fi echo "" @@ -230,5 +335,5 @@ else echo "[5/5] Skipping deploy (BuildOnly mode)..." echo "" echo "===== Build Complete =====" - echo "Image: ${IMAGE}:${TAG}" + echo "Image: ${IMAGE}:${IMAGE_TAG}" fi diff --git a/src/Game.Client/Assets/Photon/Fusion/Resources/NetworkProjectConfig.fusion b/src/Game.Client/Assets/Photon/Fusion/Resources/NetworkProjectConfig.fusion index a7c2af0c6..3619fa79c 100644 --- a/src/Game.Client/Assets/Photon/Fusion/Resources/NetworkProjectConfig.fusion +++ b/src/Game.Client/Assets/Photon/Fusion/Resources/NetworkProjectConfig.fusion @@ -13,7 +13,7 @@ "NetworkIdIsObjectName": false, "HideNetworkObjectInactivityGuard": false, "AllowClientServerModesInWebGL": false, - "ClientsRecordFrameAndPacketTimingTraces": false, + "ClientsRecordFrameAndPacketTimingTraces": true, "Simulation": { "InputDataWordCount": 0, "ReplicationFeatures": 1, @@ -21,13 +21,13 @@ "SimulationUpdateTimeMode": 0, "PlayerCount": 10, "TickRateSelection": { - "Client": 64, + "Client": 60, "ServerIndex": 0, - "ClientSendIndex": 1, - "ServerSendIndex": 1 + "ClientSendIndex": 0, + "ServerSendIndex": 0 }, "MaxObjectDestroysSentPerPacket": 32, - "EnableExperimentalPacketLossRecovery": false, + "EnableExperimentalPacketLossRecovery": true, "EnableAdaptivePacketFragmentation": false }, "Network": { diff --git a/src/Game.Client/Assets/Programs/Editor/Build/BuildScript.cs b/src/Game.Client/Assets/Programs/Editor/Build/BuildScript.cs index 14f758fa8..8fc8b5434 100644 --- a/src/Game.Client/Assets/Programs/Editor/Build/BuildScript.cs +++ b/src/Game.Client/Assets/Programs/Editor/Build/BuildScript.cs @@ -124,7 +124,7 @@ public static void BuildWindowsDevelopment() public static void BuildLinux() { var scenes = GetBuildScenes(); - var buildPath = GetBuildPath("Linux", Application.productName); + var buildPath = GetBuildPath("Linux", $"{Application.productName}.x86_64"); BuildPlayer(scenes, buildPath, BuildTarget.StandaloneLinux64, BuildOptions.None); } @@ -136,7 +136,7 @@ public static void BuildLinux() public static void BuildLinuxDevelopment() { var scenes = GetBuildScenes(); - var buildPath = GetBuildPath("Linux-Dev", Application.productName); + var buildPath = GetBuildPath("Linux-Dev", $"{Application.productName}.x86_64"); BuildPlayer(scenes, buildPath, BuildTarget.StandaloneLinux64, BuildOptions.Development | BuildOptions.AllowDebugging); @@ -277,7 +277,7 @@ public static void BuildDedicatedServerLinux() { var scenes = GetBuildScenes(); var buildPath = GetBuildPath("Server/Linux", - Application.productName); + $"{Application.productName}.x86_64"); BuildPlayer(scenes, buildPath, BuildTarget.StandaloneLinux64, BuildOptions.None, (int)StandaloneBuildSubtarget.Server); @@ -291,7 +291,7 @@ public static void BuildDedicatedServerLinuxDevelopment() { var scenes = GetBuildScenes(); var buildPath = GetBuildPath("Server/Linux-Dev", - Application.productName); + $"{Application.productName}.x86_64"); BuildPlayer(scenes, buildPath, BuildTarget.StandaloneLinux64, BuildOptions.Development | BuildOptions.AllowDebugging, diff --git a/src/Game.Client/Assets/Programs/Editor/Survivor/ItemLayerSetter.cs b/src/Game.Client/Assets/Programs/Editor/Survivor/ItemLayerSetter.cs index b9063eb2d..eb4181bbb 100644 --- a/src/Game.Client/Assets/Programs/Editor/Survivor/ItemLayerSetter.cs +++ b/src/Game.Client/Assets/Programs/Editor/Survivor/ItemLayerSetter.cs @@ -1,3 +1,4 @@ +using Game.Shared.Extensions; using UnityEditor; using UnityEngine; @@ -39,7 +40,7 @@ public static void SetItemLayerOnPrefabs() using (var editingScope = new PrefabUtility.EditPrefabContentsScope(assetPath)) { var root = editingScope.prefabContentsRoot; - SetLayerRecursively(root, itemLayer); + root.SetLayerRecursively(itemLayer); } modifiedCount++; } @@ -47,17 +48,8 @@ public static void SetItemLayerOnPrefabs() AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); - - Debug.Log($"[ItemLayerSetter] Modified {modifiedCount} prefabs. Set layer to '{ItemLayerName}'."); - } - private static void SetLayerRecursively(GameObject obj, int layer) - { - obj.layer = layer; - foreach (Transform child in obj.transform) - { - SetLayerRecursively(child.gameObject, layer); - } + Debug.Log($"[ItemLayerSetter] Modified {modifiedCount} prefabs. Set layer to '{ItemLayerName}'."); } } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs index 5bdb2f7a8..e1bb71e18 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyController.cs @@ -260,13 +260,13 @@ public void TakeDamage(int damage) public void SetNetworkId(int id) => _networkId = id; /// - /// Fusion Runner.DeltaTime を優先し、利用不可時は Time.deltaTime にフォールバック。 - /// ゲームロジックタイマー(攻撃クールダウン、HitStun)で使用。 + /// レンダーフレームの deltaTime を返す。 + /// Update() で呼ばれるタイマー(攻撃クールダウン、HitStun、NavMesh チェック等)に使用。 + /// Fusion Runner.DeltaTime(固定 Tick 間隔)ではなく Time.deltaTime を使用することで、 + /// Resimulation 時の多重加算を防止する。 /// internal float GetDeltaTime() { - if (_runnerService != null && _runnerService.IsActive && _runnerService.Runner != null) - return _runnerService.Runner.DeltaTime; return Time.deltaTime; } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs index 949b8794f..7e9876d16 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemySpawner.cs @@ -85,6 +85,10 @@ public class SurvivorEnemySpawner : MonoBehaviour private const float EnemySyncInterval = 0.1f; // 10Hz private float _enemySyncTimer; private int _nextNetworkId; + + // 診断: 5 秒毎にサイズサマリー + private const float DiagSummaryInterval = 5f; + private float _diagLastSummaryTime; private readonly Dictionary _enemyNetworkIds = new(); private readonly Dictionary _enemyByNetworkId = new(); private readonly HashSet _spawnedNetworkIds = new(); // クライアントに Spawn 済みの NetworkId @@ -164,6 +168,11 @@ public async UniTask InitializeAsync(SurvivorStageWaveManager waveManager) if (!_enemyPrefabs.ContainsKey(enemy.Id)) { var prefab = await _assetService.LoadAssetAsync(enemy.AssetName); + if (prefab == null) + { + Debug.LogError($"[SurvivorEnemySpawner] Failed to load prefab for enemy {enemy.Id} (AssetName={enemy.AssetName}). Skipping pool init."); + continue; + } _enemyPrefabs[enemy.Id] = prefab; // プール初期化 @@ -205,6 +214,9 @@ private SurvivorEnemyController CreateEnemy(int enemyId) var instance = Instantiate(prefab, transform); prefab.SetActive(true); + // プレハブ側 Layer 設定漏れを補うための保険(Player との物理衝突回避) + instance.SetLayerRecursively(LayerConstants.Enemy); + if (!instance.TryGetComponent(out var controller)) { Debug.LogError($"[SurvivorEnemySpawner] SurvivorEnemyController not found on prefab: {enemyId}"); @@ -239,8 +251,6 @@ private void OnWaveChanged() Debug.Log($"[SurvivorEnemySpawner] Wave started. Enemy types: {_enemySpawnList.Count}, Total: {_remainingSpawnCount}, RNG Seed: {seed}"); } - private float GetNetworkDeltaTime() => _runnerService.GetDeltaTime(); - private void Update() { // ポーズ状態の同期 @@ -251,10 +261,12 @@ private void Update() SetAllEnemiesPaused(isPaused); } + float deltaTime = _runnerService.GetRenderDeltaTime(); + // サーバー: 定期的に敵状態をバッチ送信(ポーズ中も位置同期は維持) if (_runnerService.TryGet(out var batchSync)) { - _enemySyncTimer -= GetNetworkDeltaTime(); + _enemySyncTimer -= deltaTime; if (_enemySyncTimer <= 0f) { _enemySyncTimer = EnemySyncInterval; @@ -285,12 +297,21 @@ private void Update() return; } - _spawnTimer -= GetNetworkDeltaTime(); + _spawnTimer -= deltaTime; if (_spawnTimer <= 0f && _remainingSpawnCount > 0) { SpawnNextEnemy(); } + + var now = Time.unscaledTime; + if (now - _diagLastSummaryTime >= DiagSummaryInterval) + { + _diagLastSummaryTime = now; + int poolsIdle = 0; + foreach (var kv in _pools) poolsIdle += kv.Value.Count; + Debug.Log($"[SurvivorEnemySpawner DIAG] active={_activeEnemies.Count}, poolsIdle={poolsIdle}, pendingDeaths={_pendingDeaths.Count}, enemyTypes={_pools.Count}"); + } } /// diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyView.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyView.cs index 28db932aa..9217111ad 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyView.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Enemy/SurvivorEnemyView.cs @@ -132,7 +132,7 @@ private void SpawnProxy(SurvivorNetworkEnemyStateSnapshot e) instance.name = $"EnemyProxy_{e.NetworkId}"; // Enemyレイヤー設定(子オブジェクト含む — LockOn/SphereCast検出用) - SetLayerRecursively(instance, LayerConstants.Enemy); + instance.SetLayerRecursively(LayerConstants.Enemy); // 全Colliderをトリガーに変更してキャッシュ(HandleDeath での再探索を排除) var colliders = instance.GetComponentsInChildren(); @@ -231,6 +231,10 @@ private void DespawnProxy(int id) } } + // 診断: 5 秒毎にプロキシ数サマリー + private const float DiagSummaryInterval = 5f; + private float _diagLastSummaryTime; + private void Update() { using var updateScope = s_updateMarker.Auto(); @@ -238,6 +242,13 @@ private void Update() float dt = Time.deltaTime; int frameCount = Time.frameCount; + var diagNow = Time.unscaledTime; + if (diagNow - _diagLastSummaryTime >= DiagSummaryInterval) + { + _diagLastSummaryTime = diagNow; + Debug.Log($"[SurvivorEnemyView DIAG] proxies={_proxies.Count}"); + } + // カメラがあれば視錐台平面をキャッシュ(1回/フレーム) Vector3 cameraPos = Vector3.zero; if (_camera != null) @@ -313,15 +324,6 @@ public bool IsProxyDead(int networkId) return !_proxies.TryGetValue(networkId, out var data) || data.IsDead; } - private static void SetLayerRecursively(GameObject go, int layer) - { - go.layer = layer; - foreach (Transform child in go.transform) - { - SetLayerRecursively(child.gameObject, layer); - } - } - private void OnDestroy() { _subscription?.Dispose(); diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/ItemProxyCollectible.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/ItemProxyCollectible.cs index 3543bb58c..7713edd06 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/ItemProxyCollectible.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/ItemProxyCollectible.cs @@ -23,8 +23,8 @@ public class ItemProxyCollectible : MonoBehaviour, ICollectible private float _floatTimer; private SurvivorFusionGameState _gameState; - /// アイテムID - public int ItemId { get; private set; } + /// ネットワーク個体 ID(サーバー採番、RPC 送信時に使用) + public int NetworkId { get; private set; } /// 収集済みフラグ public bool IsCollected { get; private set; } @@ -32,19 +32,19 @@ public class ItemProxyCollectible : MonoBehaviour, ICollectible /// 吸引中フラグ public bool IsAttracting => _attractTarget != null; - /// 収集時コールバック(SurvivorItemView が RPC 送信用に設定) + /// 収集時コールバック(引数: NetworkId。SurvivorItemView が RPC 送信用に設定) public event Action OnCollected; /// /// アイテムプロキシを初期化する。 /// /// アイテムのスケール値(浮遊振幅の計算に使用) - /// アイテムID + /// ネットワーク個体 ID(サーバー採番) /// ポーズ判定用ゲーム状態 - public void Initialize(float scale, int itemId, SurvivorFusionGameState gameState) + public void Initialize(float scale, int networkId, SurvivorFusionGameState gameState) { _scale = scale; - ItemId = itemId; + NetworkId = networkId; _gameState = gameState; _initialPosition = transform.position; _floatTimer = 0f; @@ -69,7 +69,7 @@ public void Collect() { if (IsCollected) return; IsCollected = true; - OnCollected?.Invoke(ItemId); + OnCollected?.Invoke(NetworkId); } /// diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItem.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItem.cs index afc7321c0..9a2b7c423 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItem.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItem.cs @@ -63,6 +63,12 @@ public class SurvivorItem : MonoBehaviour, ICollectible // Properties public int ItemId => _itemId; + + /// ネットワーク同期用の個体 ID(Spawner が採番して SetNetworkId で設定) + public int NetworkId { get; private set; } = -1; + + public void SetNetworkId(int networkId) => NetworkId = networkId; + public SurvivorItemType ItemType => _itemType; public int EffectValue => _effectValue; public int EffectRange => _effectRange; diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemSpawner.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemSpawner.cs index a4d39d621..45fe05808 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemSpawner.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemSpawner.cs @@ -43,6 +43,10 @@ public class SurvivorItemSpawner : MonoBehaviour // ドロップグループキャッシュ (GroupId -> List) private readonly Dictionary> _dropGroupCache = new(); + // ネットワーク個体 ID 管理(サーバー側) + private int _nextNetworkId; + private readonly Dictionary _itemsByNetworkId = new(); + // ポーズ参照 private SurvivorFusionGameState _gameState; @@ -69,6 +73,14 @@ public bool TryGetItemMaster(int itemId, out SurvivorItemMaster master) return master != null; } + /// + /// ネットワーク個体 ID から実体を取得(サーバー側アイテム収集処理用) + /// + public bool TryGetItemByNetworkId(int networkId, out SurvivorItem item) + { + return _itemsByNetworkId.TryGetValue(networkId, out item); + } + /// /// アイテムマスターを取得(遅延読み込み&キャッシュ) /// @@ -221,11 +233,15 @@ public void SpawnItem(int itemId, Vector3 position) item.SetPosition(position); item.gameObject.SetActive(true); + int networkId = _nextNetworkId++; + item.SetNetworkId(networkId); + _itemsByNetworkId[networkId] = item; + _activeItems[itemId].Add(item); - // サーバー: クライアントにアイテムスポーンを通知 + // サーバー: クライアントにアイテムスポーンを通知(networkId で個体識別) if (_runnerService.TryGet(out var gs)) - gs.NotifyItemSpawned(itemId, position.x, position.y, position.z); + gs.NotifyItemSpawned(networkId, itemId, position.x, position.y, position.z); } } @@ -371,10 +387,11 @@ private void OnItemCollectedHandler(SurvivorItem item) { _onItemCollected.OnNext(item); - // サーバー: クライアントにアイテム回収を通知 + // サーバー: クライアントにアイテム回収を通知(networkId で個体識別) if (_runnerService.TryGet(out var gs)) - gs.NotifyItemDespawned(item.ItemId); + gs.NotifyItemDespawned(item.NetworkId); + _itemsByNetworkId.Remove(item.NetworkId); ReturnToPool(item); } @@ -395,6 +412,8 @@ public void ClearAllItems() } kvp.Value.Clear(); } + _itemsByNetworkId.Clear(); + _nextNetworkId = 0; } private void OnDestroy() @@ -429,6 +448,7 @@ private void OnDestroy() } } _activeItems.Clear(); + _itemsByNetworkId.Clear(); // ロードしたプレハブをリリース foreach (var prefab in _prefabCache.Values) diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemView.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemView.cs index 7f6d2ceef..553bfad16 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemView.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Item/SurvivorItemView.cs @@ -62,21 +62,20 @@ public async UniTask InitializeAsync( } } - _spawnSub = spawnSub.Subscribe(s => OnSpawned(s.ItemId, s.PosX, s.PosY, s.PosZ)); - _despawnSub = despawnSub.Subscribe(s => OnDespawned(s.ItemId)); + _spawnSub = spawnSub.Subscribe(s => OnSpawned(s.NetworkId, s.ItemId, s.PosX, s.PosY, s.PosZ)); + _despawnSub = despawnSub.Subscribe(s => OnDespawned(s.NetworkId)); Debug.Log($"[SurvivorItemView] Initialized: prefabs={_prefabs.Count}"); } - private void OnSpawned(int itemId, float posX, float posY, float posZ) + private void OnSpawned(int networkId, int itemId, float posX, float posY, float posZ) { var position = new Vector3(posX, posY, posZ); - // 既存プロキシの再利用 - if (_proxies.TryGetValue(itemId, out var existing)) + // 既存プロキシがある場合は破棄(networkId 再利用時の安全策) + if (_proxies.TryGetValue(networkId, out var existing)) { - existing.GameObject.transform.position = position; - existing.Collectible.Reset(); - return; + if (existing.GameObject != null) Destroy(existing.GameObject); + _proxies.Remove(networkId); } float scale = 1f; @@ -109,20 +108,20 @@ private void OnSpawned(int itemId, float posX, float posY, float posZ) Debug.LogWarning($"[SurvivorItemView] Prefab not found for item {itemId}, using fallback"); } - instance.name = $"ItemProxy_{itemId}"; + instance.name = $"ItemProxy_{networkId}"; instance.transform.position = position; instance.transform.localScale = Vector3.one * scale; instance.transform.SetParent(transform); // Item レイヤー設定(PlayerController の OverlapSphere 検出用) - SetLayerRecursively(instance, LayerConstants.Item); + instance.SetLayerRecursively(LayerConstants.Item); // ICollectible プロキシ追加(PlayerController の吸引・収集ロジックで動作) var collectible = instance.AddComponent(); - collectible.Initialize(scale, itemId, _gameState); + collectible.Initialize(scale, networkId, _gameState); collectible.OnCollected += OnProxyItemCollectedHandler; - _proxies[itemId] = new ItemProxyData + _proxies[networkId] = new ItemProxyData { GameObject = instance, Collectible = collectible, @@ -130,33 +129,38 @@ private void OnSpawned(int itemId, float posX, float posY, float posZ) }; } - private void OnDespawned(int itemId) + private void OnDespawned(int networkId) { - if (_proxies.TryGetValue(itemId, out var data)) + if (_proxies.TryGetValue(networkId, out var data)) { if (data.GameObject != null) Destroy(data.GameObject); - _proxies.Remove(itemId); + _proxies.Remove(networkId); } } - private void OnProxyItemCollectedHandler(int itemId) + private void OnProxyItemCollectedHandler(int networkId) { - OnProxyItemCollected?.Invoke(itemId); + OnProxyItemCollected?.Invoke(networkId); // クライアント側で即座にプロキシを削除(サーバーの Despawn RPC を待たない) - if (_proxies.TryGetValue(itemId, out var data)) + if (_proxies.TryGetValue(networkId, out var data)) { if (data.GameObject != null) Destroy(data.GameObject); - _proxies.Remove(itemId); + _proxies.Remove(networkId); } } - private static void SetLayerRecursively(GameObject go, int layer) + // 診断: 5 秒毎にプロキシ数サマリー + private const float DiagSummaryInterval = 5f; + private float _diagLastSummaryTime; + + private void Update() { - go.layer = layer; - foreach (Transform child in go.transform) + var now = Time.unscaledTime; + if (now - _diagLastSummaryTime >= DiagSummaryInterval) { - SetLayerRecursively(child.gameObject, layer); + _diagLastSummaryTime = now; + Debug.Log($"[SurvivorItemView DIAG] proxies={_proxies.Count}, prefabsLoaded={_prefabs.Count}"); } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Player/SurvivorPlayerController.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Player/SurvivorPlayerController.cs index 3bf7e91af..a7c59aee9 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Player/SurvivorPlayerController.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Player/SurvivorPlayerController.cs @@ -354,7 +354,7 @@ private void UpdateItemAttraction() { if (_fusionPlayer == null || !_fusionPlayer.HasInputAuthority) return; if (_gameState != null && _gameState.IsEffectivelyPaused) return; - _itemCheckTimer -= _runnerService.GetDeltaTime(); + _itemCheckTimer -= _runnerService.GetRenderDeltaTime(); if (_itemCheckTimer > 0f) return; _itemCheckTimer = ItemCheckInterval; diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs index 61e63beae..255989472 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorNetworkStageScene.cs @@ -274,7 +274,7 @@ private void SetupServerNetworking() // 武器適用・ヒット報告・アイテム収集報告シグナル購読 _weaponApplySub.Subscribe(s => OnServerWeaponApply(s.Request)).AddTo(Disposables); _hitReportedSub.Subscribe(s => OnServerHitReported(s.EnemyNetworkId, s.WeaponId)).AddTo(Disposables); - _itemCollectReportedSub.Subscribe(s => OnServerItemCollectReported(s.ItemId)).AddTo(Disposables); + _itemCollectReportedSub.Subscribe(s => OnServerItemCollectReported(s.NetworkId)).AddTo(Disposables); // シグナル→ClientRpcブリッジ SubscribeNetworkSignals(); @@ -350,13 +350,17 @@ private void OnServerHitReported(int enemyNetworkId, int weaponId) /// /// サーバー: クライアントからのアイテム収集報告を処理。 - /// マスターデータからアイテム効果を取得し、モデルに適用後、結果を全クライアントに通知。 + /// networkId で個体を取得してマスターデータからアイテム効果を取得し、モデルに適用後、結果を全クライアントに通知。 /// - private void OnServerItemCollectReported(int itemId) + private void OnServerItemCollectReported(int networkId) { var itemSpawner = SceneComponent.SurvivorItemSpawner; if (itemSpawner == null) return; + // networkId から個体を取得(すでに破棄済みの報告は無視) + if (!itemSpawner.TryGetItemByNetworkId(networkId, out var item)) return; + int itemId = item.ItemId; + // マスターデータからアイテム情報を取得 if (!itemSpawner.TryGetItemMaster(itemId, out var master)) return; @@ -374,14 +378,14 @@ private void OnServerItemCollectReported(int itemId) break; } - // 全クライアントに結果を通知 + // 全クライアントに結果を通知(NotifyItemCollected は ItemId、NotifyItemDespawned は networkId) if (_runnerService.TryGet(out var gs)) { gs.NotifyItemCollected( "", itemId, (int)itemType, effectValue, _stageModel.Experience.Value, _stageModel.ExperienceToNextLevel.Value); - gs.NotifyItemDespawned(itemId); + gs.NotifyItemDespawned(networkId); } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs index 2ebae389d..28ac757a6 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageConnectScene.cs @@ -169,23 +169,38 @@ private async UniTask PrepareClientConnectionAsync(int stageId) return; } - // FusionServerAddress が設定されていればクラウドサーバーに接続 + // DS アドレスをトークンレスポンスから動的取得し、接続先を決定する。 + // 優先順位: 1) レスポンスの ServerAddress → Remote 接続 + // 2) envConfig.UnityServerAddress → Remote フォールバック + // 3) それ以外 → Local 接続(127.0.0.1) + var tokenResult = await IssueTokenAsync(stageId); var envConfig = GameEnvironmentHelper.CurrentConfig; - if (envConfig != null && !_sessionConfig.IsLocalAddress(envConfig.UnityServerAddress)) + + if (!string.IsNullOrEmpty(tokenResult?.ServerAddress) && tokenResult.ServerPort > 0) { - var tokenResult = await IssueTokenAsync(stageId); + // DS 割り当て済み: レスポンスに含まれる DS アドレスへ直接接続 _sessionConfig.Configure(ConnectionSource.Remote, - address: envConfig.UnityServerAddress, - port: envConfig.UnityServerPort, + address: tokenResult.ServerAddress, + port: (ushort)tokenResult.ServerPort, sessionName: tokenResult.SessionName, sessionToken: tokenResult.Token); - Debug.Log($"[SurvivorStageConnectScene] Connecting to remote server: {_sessionConfig.ServerAddress}:{_sessionConfig.ServerPort} ({_sessionConfig.SessionName})"); + Debug.Log($"[SurvivorStageConnectScene] DS アドレスをトークンレスポンスから取得: {_sessionConfig.ServerAddress}:{_sessionConfig.ServerPort} ({_sessionConfig.SessionName})"); + } + else if (envConfig != null && !_sessionConfig.IsLocalAddress(envConfig.UnityServerAddress)) + { + // envConfig にリモートアドレスが設定されている場合のフォールバック(ローカル開発用) + _sessionConfig.Configure(ConnectionSource.Remote, + address: envConfig.UnityServerAddress, + port: envConfig.UnityServerPort, + sessionName: tokenResult?.SessionName, + sessionToken: tokenResult?.Token); + Debug.Log($"[SurvivorStageConnectScene] envConfig フォールバック: {_sessionConfig.ServerAddress}:{_sessionConfig.ServerPort} ({_sessionConfig.SessionName})"); } else { - var defaultTokenResult = await IssueTokenAsync(stageId); - _sessionConfig.Configure(ConnectionSource.Local, sessionName: defaultTokenResult.SessionName, sessionToken: defaultTokenResult.Token); - Debug.Log($"[SurvivorStageConnectScene] Connecting to local server ({_sessionConfig.SessionName})..."); + // ローカル接続(127.0.0.1) + _sessionConfig.Configure(ConnectionSource.Local, sessionName: tokenResult?.SessionName, sessionToken: tokenResult?.Token); + Debug.Log($"[SurvivorStageConnectScene] ローカルサーバーへ接続 ({_sessionConfig.SessionName})..."); } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs index a70aa4230..5242295ce 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorStageScene.States.cs @@ -200,12 +200,12 @@ await itemView.InitializeAsync( Context._addressableService, itemViewGameState); - // アイテムプロキシ収集時にサーバーへ RPC 送信 - itemView.OnProxyItemCollected += itemId => + // アイテムプロキシ収集時にサーバーへ RPC 送信(networkId で個体識別) + itemView.OnProxyItemCollected += networkId => { if (TryGetLocalPlayer(out var localPlayer)) { - localPlayer.RpcClientItemCollected(itemId); + localPlayer.RpcClientItemCollected(networkId); } }; } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorTotalResultScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorTotalResultScene.cs index fb34dff2b..6a6d9a443 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorTotalResultScene.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Scenes/SurvivorTotalResultScene.cs @@ -26,6 +26,7 @@ public class SurvivorTotalResultScene : GamePrefabScene 0) + { + _requestQueue.ProcessQueueAsync().Forget(); + } } private async UniTaskVoid OnRetry() diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundDamageArea.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundDamageArea.cs index 3ee8f332d..e896464fc 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundDamageArea.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorGroundDamageArea.cs @@ -78,7 +78,7 @@ private void Update() if (!_isActive) return; if (_gameState != null && _gameState.IsEffectivelyPaused) return; - float deltaTime = _runnerService.GetDeltaTime(); + float deltaTime = _runnerService.GetRenderDeltaTime(); _remainingTime -= deltaTime; _nextProcTime -= deltaTime; diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorProjectile.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorProjectile.cs index fabc4f409..d045ce191 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorProjectile.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorProjectile.cs @@ -118,7 +118,7 @@ private void Update() if (!_isActive) return; if (_gameState != null && _gameState.IsEffectivelyPaused) return; - float deltaTime = _runnerService.GetDeltaTime(); + float deltaTime = _runnerService.GetRenderDeltaTime(); // 追尾処理 if (_homing > 0 && _homingTarget != null && _homingTarget.gameObject.activeInHierarchy) diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs index 2e963397d..ce631eccb 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponBase.cs @@ -142,6 +142,12 @@ public abstract class SurvivorWeaponBase : IDisposable public string HitEffectAssetName => _hitEffectAssetName; public float HitEffectScale => _hitEffectScale; + /// プールアイテムの使用中数(プール無し武器は 0)。診断用 + public virtual int ActivePoolItemCount => 0; + + /// プールアイテムの待機中数(プール無し武器は 0)。診断用 + public virtual int IdlePoolItemCount => 0; + // 手動発動関連 public bool IsManualActivation => _cooldown > 0; public bool IsOnCooldown => _cooldownTimer > 0f; @@ -481,6 +487,9 @@ public abstract class SurvivorWeaponBase : SurvivorWeaponBase protected WeaponObjectPool CurrentPool { get; private set; } protected bool IsPoolInitialized { get; private set; } + public override int ActivePoolItemCount => _poolsByAssetName.Values.Sum(p => p.ActiveCount); + public override int IdlePoolItemCount => _poolsByAssetName.Values.Sum(p => p.IdleCount); + protected SurvivorWeaponBase(SurvivorWeaponMaster weaponMaster) : base(weaponMaster) { } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs index 2464a80a9..236e45087 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponManager.cs @@ -284,6 +284,10 @@ public async UniTask ReplaceWeaponAsync(int removeWeaponId, int newWeaponI return result; } + // 診断: 5 秒毎に武器プールのサイズサマリー + private const float DiagSummaryInterval = 5f; + private float _diagLastSummaryTime; + /// /// 毎フレーム全武器を更新 /// @@ -291,11 +295,26 @@ private void Update() { if (_gameState != null && _gameState.IsEffectivelyPaused) return; - float deltaTime = _runnerService.GetDeltaTime(); + float deltaTime = _runnerService.GetRenderDeltaTime(); foreach (var weapon in _weapons) { weapon.UpdateWeapon(deltaTime); } + + var now = Time.unscaledTime; + if (now - _diagLastSummaryTime >= DiagSummaryInterval) + { + _diagLastSummaryTime = now; + int activeTotal = 0, idleTotal = 0; + foreach (var w in _weapons) + { + activeTotal += w.ActivePoolItemCount; + idleTotal += w.IdlePoolItemCount; + } + int vfxPools = _vfxSpawner?.PoolCount ?? 0; + int vfxIdle = _vfxSpawner?.TotalIdleParticles ?? 0; + Debug.Log($"[SurvivorWeaponManager DIAG] weapons={_weapons.Count}, poolActive={activeTotal}, poolIdle={idleTotal}, vfxPools={vfxPools}, vfxIdle={vfxIdle}"); + } } private void OnDestroy() diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponVfxSpawner.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponVfxSpawner.cs index 27e4822bc..4c433e3ce 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponVfxSpawner.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/SurvivorWeaponVfxSpawner.cs @@ -28,6 +28,20 @@ public class SurvivorWeaponVfxSpawner : IDisposable private readonly Dictionary _prefabCache = new(); private readonly HashSet _loadingAssets = new(); + /// プール数(AssetName 毎)。診断用 + public int PoolCount => _pools.Count; + + /// 全プールの待機中 ParticleSystem 総数。診断用 + public int TotalIdleParticles + { + get + { + int total = 0; + foreach (var pool in _pools.Values) total += pool.Count; + return total; + } + } + public SurvivorWeaponVfxSpawner( Transform parent, IAddressableAssetService assetService, diff --git a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/WeaponObjectPool.cs b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/WeaponObjectPool.cs index 9872e93bd..f5a07109f 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/WeaponObjectPool.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVP/Survivor/Weapon/WeaponObjectPool.cs @@ -27,6 +27,11 @@ public class WeaponObjectPool where T : MonoBehaviour, IPoolableWeaponItem /// public int ActiveCount => _activeItems.Count; + /// + /// プール内に待機中のアイテム数 + /// + public int IdleCount => _pool.Count; + /// /// コンストラクタ /// diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Environment/EnvVarKeys.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Environment/EnvVarKeys.cs index d93970f06..e994564bf 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Environment/EnvVarKeys.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Environment/EnvVarKeys.cs @@ -17,6 +17,12 @@ public static class EnvVarKeys /// public const string PublicAddress = "PUBLIC_ADDRESS"; + /// + /// DS の VPC 内部 IP アドレスの環境変数キー名。 + /// Game.Server → DS 間の HTTP 通信(VPC Connector 経由)に使用する。 + /// + public const string InternalAddress = "INTERNAL_ADDRESS"; + /// /// Unity Dedicated Server の Fusion UDP ポート番号の環境変数キー名。 /// diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Extensions/GameObjectExtensions.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Extensions/GameObjectExtensions.cs new file mode 100644 index 000000000..31db55cc0 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Extensions/GameObjectExtensions.cs @@ -0,0 +1,24 @@ +using UnityEngine; + +namespace Game.Shared.Extensions +{ + /// + /// GameObject 用の拡張メソッド。 + /// + public static class GameObjectExtensions + { + /// + /// GameObject とその全子孫の Layer を再帰的に設定する。 + /// プレハブ側 Layer 設定漏れの保険としてランタイムで強制適用する用途。 + /// + public static void SetLayerRecursively(this GameObject go, int layer) + { + if (go == null) return; + go.layer = layer; + foreach (Transform child in go.transform) + { + SetLayerRecursively(child.gameObject, layer); + } + } + } +} diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Extensions/GameObjectExtensions.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Extensions/GameObjectExtensions.cs.meta new file mode 100644 index 000000000..2b6a7ca3f --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Extensions/GameObjectExtensions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9598aed1f59297c4b9e297cc2b424ddf \ No newline at end of file diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Fusion/FusionRunnerServiceExtensions.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Fusion/FusionRunnerServiceExtensions.cs index 60a42abbc..82c2865c3 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Fusion/FusionRunnerServiceExtensions.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Fusion/FusionRunnerServiceExtensions.cs @@ -34,5 +34,17 @@ public static float GetDeltaTime(this IFusionRunnerService runnerService) return runnerService.Runner.DeltaTime; return Time.deltaTime; } + + /// + /// MonoBehaviour.Update() 内で使用するレンダーフレームの deltaTime を返す。 + /// Fusion の Runner.DeltaTime(固定 Tick 間隔)ではなく、Unity の Time.deltaTime を使用。 + /// 武器タイマー、エネミーAI タイマー等の非ネットワーク同期処理に適切。 + /// + /// FusionRunnerService。 + /// レンダーフレームのデルタタイム。 + public static float GetRenderDeltaTime(this IFusionRunnerService service) + { + return Time.deltaTime; + } } } diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs index 9f771e1fe..afc8c693d 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionGameState.cs @@ -370,32 +370,32 @@ public void RpcNotifyPlayerDisconnected(NetworkString<_64> userId, NetworkString // アイテム同期(RPC 経由で全クライアントに配信) // ===================================================================== - /// サーバー側: アイテムスポーンを全クライアントに通知 - public void NotifyItemSpawned(int itemId, float posX, float posY, float posZ) + /// サーバー側: アイテムスポーンを全クライアントに通知(networkId で個体識別) + public void NotifyItemSpawned(int networkId, int itemId, float posX, float posY, float posZ) { if (!HasStateAuthority) return; - RpcNotifyItemSpawned(itemId, posX, posY, posZ); + RpcNotifyItemSpawned(networkId, itemId, posX, posY, posZ); } [Rpc(RpcSources.StateAuthority, RpcTargets.All)] - public void RpcNotifyItemSpawned(int itemId, float posX, float posY, float posZ) + public void RpcNotifyItemSpawned(int networkId, int itemId, float posX, float posY, float posZ) { - Debug.Log($"[SurvivorFusionGameState] RpcItemSpawned: id={itemId}"); - _itemSpawnedPub?.Publish(new SurvivorSignals.Item.Spawned(itemId, posX, posY, posZ)); + Debug.Log($"[SurvivorFusionGameState] RpcItemSpawned: nid={networkId}, id={itemId}"); + _itemSpawnedPub?.Publish(new SurvivorSignals.Item.Spawned(networkId, itemId, posX, posY, posZ)); } - /// サーバー側: アイテムデスポーンを全クライアントに通知 - public void NotifyItemDespawned(int itemId) + /// サーバー側: アイテムデスポーンを全クライアントに通知(networkId で個体識別) + public void NotifyItemDespawned(int networkId) { if (!HasStateAuthority) return; - RpcNotifyItemDespawned(itemId); + RpcNotifyItemDespawned(networkId); } [Rpc(RpcSources.StateAuthority, RpcTargets.All)] - public void RpcNotifyItemDespawned(int itemId) + public void RpcNotifyItemDespawned(int networkId) { - Debug.Log($"[SurvivorFusionGameState] RpcItemDespawned: id={itemId}"); - _itemDespawnedPub?.Publish(new SurvivorSignals.Item.Despawned(itemId)); + Debug.Log($"[SurvivorFusionGameState] RpcItemDespawned: nid={networkId}"); + _itemDespawnedPub?.Publish(new SurvivorSignals.Item.Despawned(networkId)); } // ===================================================================== @@ -500,10 +500,10 @@ public void OnClientHitReported(int enemyNetworkId, int weaponId) _hitReportedPub?.Publish(new SurvivorSignals.Weapon.HitReported(enemyNetworkId, weaponId)); } - /// サーバー側: クライアントからのアイテム収集報告 - public void OnClientItemCollected(int itemId) + /// サーバー側: クライアントからのアイテム収集報告(networkId で個体識別) + public void OnClientItemCollected(int networkId) { - _itemCollectReportedPub?.Publish(new SurvivorSignals.Item.CollectReported(itemId)); + _itemCollectReportedPub?.Publish(new SurvivorSignals.Item.CollectReported(networkId)); } // ===================================================================== diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs index 2928ec3d0..e92fb25ac 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionPlayer.cs @@ -3,6 +3,8 @@ using Fusion; using Fusion.Addons.FSM; using Fusion.Addons.KCC; +using Game.Shared.Constants; +using Game.Shared.Extensions; using Game.Shared.Network.Fusion; using Game.Shared.Signals.Survivor; using MessagePipe; @@ -140,6 +142,9 @@ public override void Spawned() TryGetComponent(out _kcc); _runnerService?.TryGet(out _gameState); + // プレハブ側 Layer 設定漏れを補うための保険(敵との物理衝突回避) + gameObject.SetLayerRecursively(LayerConstants.Player); + // Fusion FSM: Awake で作成済み → Spawned で初期ステート設定 if (_playerFsm != null) { @@ -232,6 +237,10 @@ public override void FixedUpdateNetwork() { if (!HasStateAuthority && !HasInputAuthority) return; + // Resimulation 中はクライアント側の予測再計算をスキップ + // State Authority(サーバー)は Resimulation しないため影響なし + if (Runner.IsResimulation) return; + // 診断: FixedUpdate での状態記録(サーバー・クライアント両方) if (_kcc != null) { @@ -265,6 +274,12 @@ public override void FixedUpdateNetwork() if (GetInput(out SurvivorPlayerNetworkInput input)) { + // 診断: サーバー側で連続欠損から復帰した場合ログ + if (HasStateAuthority && _ticksSinceLastInput >= 5) + { + Debug.Log($"[FusionPlayer DIAG-SRV] Input restored after {_ticksSinceLastInput} missing ticks (tick={Runner.Tick})"); + } + _inputReceived = true; _ticksSinceLastInput = 0; @@ -297,6 +312,12 @@ public override void FixedUpdateNetwork() { _ticksSinceLastInput++; + // 診断: サーバー側で連続 5 tick 超の入力欠損を検知した最初の tick でログ + if (HasStateAuthority && _ticksSinceLastInput == 5) + { + Debug.LogWarning($"[FusionPlayer DIAG-SRV] Input missing: 5+ consecutive ticks begin (tick={Runner.Tick})"); + } + // 1-2tickの一時的な入力途絶では KCC の既存入力を維持し、クライアント予測との乖離を防ぐ var isBeforeFirstInput = !_inputReceived; var isInputTimeout = _inputReceived && _ticksSinceLastInput > InputTimeoutTicks; @@ -468,11 +489,11 @@ public void RpcClientHitReported(int enemyNetworkId, int weaponId) } [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)] - public void RpcClientItemCollected(int itemId) + public void RpcClientItemCollected(int networkId) { if (TryGetGameState(out var gs)) { - gs.OnClientItemCollected(itemId); + gs.OnClientItemCollected(networkId); } } diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs index 53b4c27df..0883dc171 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Network/Survivor/SurvivorFusionRunner.cs @@ -41,6 +41,34 @@ public void Initialize() DontDestroyOnLoad(gameObject); } + // 診断: 5 秒間の FPS / 最大フレーム時間 / OnInput 呼出回数を集計 + private const float DiagSummaryInterval = 5f; + private int _diagFrameCount; + private int _diagOnInputCount; + private float _diagMaxFrameTime; + private float _diagLastSummaryTime; + + private void Update() + { + if (Runner == null || !Runner.IsRunning) return; + + _diagFrameCount++; + var dt = Time.unscaledDeltaTime; + if (dt > _diagMaxFrameTime) _diagMaxFrameTime = dt; + + var now = Time.unscaledTime; + var elapsed = now - _diagLastSummaryTime; + if (elapsed >= DiagSummaryInterval) + { + var fps = _diagFrameCount / elapsed; + Debug.Log($"[FusionRunner DIAG] FPS={fps:F1}, MaxFrameTime={_diagMaxFrameTime * 1000f:F2}ms, OnInputCalls={_diagOnInputCount} (window={elapsed:F1}s, mode={Runner.GameMode})"); + _diagFrameCount = 0; + _diagOnInputCount = 0; + _diagMaxFrameTime = 0f; + _diagLastSummaryTime = now; + } + } + /// /// Fusion セッションを開始する。 /// FusionConnectionConfig に必要なパラメータをすべてまとめて受け取る。 @@ -112,6 +140,7 @@ public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) public void OnInput(NetworkRunner runner, NetworkInput input) { + _diagOnInputCount++; if (InputProvider != null) { input.Set(InputProvider()); diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs index 266318b10..07e372140 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Signals/SurvivorSignals.cs @@ -286,13 +286,15 @@ public static class Item { public readonly struct Spawned { + public readonly int NetworkId; public readonly int ItemId; public readonly float PosX; public readonly float PosY; public readonly float PosZ; - public Spawned(int itemId, float posX, float posY, float posZ) + public Spawned(int networkId, int itemId, float posX, float posY, float posZ) { + NetworkId = networkId; ItemId = itemId; PosX = posX; PosY = posY; @@ -302,22 +304,22 @@ public Spawned(int itemId, float posX, float posY, float posZ) public readonly struct Despawned { - public readonly int ItemId; + public readonly int NetworkId; - public Despawned(int itemId) + public Despawned(int networkId) { - ItemId = itemId; + NetworkId = networkId; } } /// クライアント→サーバー: アイテム収集報告 public readonly struct CollectReported { - public readonly int ItemId; + public readonly int NetworkId; - public CollectReported(int itemId) + public CollectReported(int networkId) { - ItemId = itemId; + NetworkId = networkId; } } } diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/GceMetadataDetector.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/GceMetadataDetector.cs index f6a685bda..009d13d2c 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/GceMetadataDetector.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/GceMetadataDetector.cs @@ -6,30 +6,53 @@ namespace Game.Shared.Unity.Server { /// - /// GCE (Google Compute Engine) のメタデータサーバーから外部 IP を取得する。 + /// GCE (Google Compute Engine) のメタデータサーバーから外部 IP / 内部 IP を取得する。 /// 非 GCE 環境では 2 秒 timeout で silent fail し、null を返す。 /// public static class GceMetadataDetector { private static readonly HttpClient s_client = new HttpClient(); - private const string MetadataUrl = + private const string ExternalIpMetadataUrl = "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip"; + private const string InternalIpMetadataUrl = + "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/ip"; + /// /// GCE メタデータサーバーから外部 IP を非同期で取得する。 /// 非 GCE 環境または 2 秒以内に応答がない場合は null を返す。 /// /// キャンセルトークン。 /// 外部 IP 文字列。失敗時は null。 - public static async UniTask TryFetchExternalIpAsync(CancellationToken ct) + public static UniTask TryFetchExternalIpAsync(CancellationToken ct) + => FetchMetadataAsync(ExternalIpMetadataUrl, ct); + + /// + /// GCE メタデータサーバーから内部 IP(VPC プライベート IP)を非同期で取得する。 + /// 非 GCE 環境または 2 秒以内に応答がない場合は null を返す。 + /// Game.Server → DS 間の HTTP 通信(VPC Connector 経由)に使用する。 + /// + /// キャンセルトークン。 + /// 内部 IP 文字列。失敗時は null。 + public static UniTask TryFetchInternalIpAsync(CancellationToken ct) + => FetchMetadataAsync(InternalIpMetadataUrl, ct); + + /// + /// GCE メタデータサーバーから指定 URL のメタデータを非同期で取得する。 + /// 非 GCE 環境または 2 秒以内に応答がない場合は null を返す。 + /// + /// メタデータ取得先 URL。 + /// キャンセルトークン。 + /// メタデータ文字列。失敗時は null。 + private static async UniTask FetchMetadataAsync(string url, CancellationToken ct) { try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(2)); - using var req = new HttpRequestMessage(HttpMethod.Get, MetadataUrl); + using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Add("Metadata-Flavor", "Google"); using var res = await s_client.SendAsync(req, cts.Token); diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfig.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfig.cs index 784f0a44e..3e7723a08 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfig.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfig.cs @@ -26,9 +26,15 @@ public sealed class UnityServerConfig /// public ReadOnlyMemory AuthSecretKey { get; } - /// DS の公開アドレス(GCE 外部 IP または手動設定)。未設定時は null。 + /// DS の公開アドレス(GCE 外部 IP または手動設定)。クライアント UDP 接続用。未設定時は null。 public string PublicAddress { get; } + /// + /// DS の VPC 内部 IP アドレス(GCE 内部 IP または手動設定)。 + /// Game.Server → DS 間の HTTP 通信(VPC Connector 経由)に使用する。未設定時は null。 + /// + public string InternalAddress { get; } + /// /// を初期化する。 /// @@ -38,13 +44,15 @@ public sealed class UnityServerConfig /// ヘルスチェックポート番号。 /// HMAC シークレット(空の場合は認証スキップ)。 /// 公開アドレス(null 可)。 + /// VPC 内部 IP アドレス(null 可)。 public UnityServerConfig( string dsId, string gameServerUrl, ushort gamePort, int healthPort, ReadOnlyMemory authSecretKey, - string publicAddress) + string publicAddress, + string internalAddress = null) { DsId = dsId; GameServerUrl = gameServerUrl; @@ -52,6 +60,7 @@ public UnityServerConfig( HealthPort = healthPort; AuthSecretKey = authSecretKey; PublicAddress = publicAddress; + InternalAddress = internalAddress; } } } diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfigFactory.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfigFactory.cs index 1444b6dbb..fc14a01d6 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfigFactory.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Config/UnityServerConfigFactory.cs @@ -15,7 +15,7 @@ public static class UnityServerConfigFactory { /// /// CLI 引数・環境変数・GCE メタデータから設定を非同期で構築する。 - /// GCE 外部 IP 取得には最大 2 秒を要する。 + /// GCE 外部 IP・内部 IP 取得を並列実行し、最大 2 秒で完了する。 /// /// キャンセルトークン。 /// 構築済みの @@ -30,11 +30,22 @@ public static async UniTask BuildAsync(CancellationToken ct) ValidateGameServerUrl(gameServerUrl); var authSecretBytes = ParseSecret(); - // GCE 環境の場合は外部 IP を取得(非 GCE では 2 秒 timeout で null) + // 環境変数で上書きされていない場合は GCE メタデータから取得する。 + // 外部 IP(クライアント UDP 接続用)と内部 IP(Game.Server HTTP 通信用)を並列取得し + // 非 GCE 環境でのタイムアウトを 2 秒に抑制する。 var envPublicAddress = EnvVarHelper.Get(EnvVarKeys.PublicAddress); - var publicAddress = !string.IsNullOrEmpty(envPublicAddress) - ? envPublicAddress - : await GceMetadataDetector.TryFetchExternalIpAsync(ct); + var envInternalAddress = EnvVarHelper.Get(EnvVarKeys.InternalAddress); + + var (gceExternalIp, gceInternalIp) = await UniTask.WhenAll( + string.IsNullOrEmpty(envPublicAddress) + ? GceMetadataDetector.TryFetchExternalIpAsync(ct) + : UniTask.FromResult(null), + string.IsNullOrEmpty(envInternalAddress) + ? GceMetadataDetector.TryFetchInternalIpAsync(ct) + : UniTask.FromResult(null)); + + var publicAddress = !string.IsNullOrEmpty(envPublicAddress) ? envPublicAddress : gceExternalIp; + var internalAddress = !string.IsNullOrEmpty(envInternalAddress) ? envInternalAddress : gceInternalIp; var authSecretKey = authSecretBytes != null ? new ReadOnlyMemory(authSecretBytes) @@ -46,7 +57,8 @@ public static async UniTask BuildAsync(CancellationToken ct) gamePort, healthPort, authSecretKey, - publicAddress); + publicAddress, + internalAddress); } /// diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs index caa45c219..1d6c02e9c 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/IUnityServerRegistryApiClient.cs @@ -14,10 +14,11 @@ public interface IUnityServerRegistryApiClient /// /// DS を Game.Server に自己登録する。 /// - /// DS の公開アドレス(IP 文字列)。 + /// DS の公開アドレス(クライアント UDP 接続用 IP 文字列)。 + /// DS の VPC 内部 IP アドレス(Game.Server HTTP 通信用)。未設定時は null。 /// キャンセルトークン。 /// 成功した場合は true。 - Task RegisterAsync(string dsAddress, CancellationToken ct); + Task RegisterAsync(string dsAddress, string internalAddress, CancellationToken ct); /// /// ハートビートを Game.Server に送信する。 diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs index 20fefbe03..ee0f9a106 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/Registry/UnityServerRegistryApiClient.cs @@ -38,7 +38,7 @@ public UnityServerRegistryApiClient(UnityServerConfigProvider configProvider) } /// - public async Task RegisterAsync(string dsAddress, CancellationToken ct) + public async Task RegisterAsync(string dsAddress, string internalAddress, CancellationToken ct) { var config = _configProvider.Current; if (string.IsNullOrEmpty(config.GameServerUrl)) @@ -50,12 +50,13 @@ public async Task RegisterAsync(string dsAddress, CancellationToken ct) { DsId = config.DsId, Address = dsAddress, + InternalAddress = internalAddress, GamePort = config.GamePort, HealthPort = config.HealthPort, }; var url = $"{config.GameServerUrl}/api/unity-server/register"; var status = await PostMessagePackAsync(url, request, config.AuthSecretKey, ct); - Debug.Log($"[UnityServerRegistryApiClient] 自己登録完了: status={status}"); + Debug.Log($"[UnityServerRegistryApiClient] 自己登録完了: status={status}, internalAddress={internalAddress ?? "(none)"}"); return true; } catch (Exception ex) diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerBootstrap.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerBootstrap.cs index cc370bd67..727ac76d9 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerBootstrap.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Unity/Server/UnityServerBootstrap.cs @@ -83,6 +83,7 @@ public async UniTask StartAsync(CancellationToken cancellation) Debug.Log($"[ServerBootstrap] DsId={config.DsId}, GamePort={config.GamePort}, HealthPort={config.HealthPort}"); Debug.Log($"[ServerBootstrap] GameServerUrl={config.GameServerUrl ?? "(none)"}"); Debug.Log($"[ServerBootstrap] PublicAddress={config.PublicAddress ?? "(none)"}"); + Debug.Log($"[ServerBootstrap] InternalAddress={config.InternalAddress ?? "(none)"}"); Debug.Log($"[ServerBootstrap] AuthSecretKey={(config.AuthSecretKey.IsEmpty ? "未設定" : "設定済み(HMAC 認証有効)")}"); // PUBLIC_ADDRESS を環境変数に書き戻す(SurvivorNetworkStageConnector が参照) @@ -109,7 +110,7 @@ public async UniTask StartAsync(CancellationToken cancellation) if (!string.IsNullOrEmpty(config.GameServerUrl)) { var dsAddress = config.PublicAddress ?? NetworkAddressHelper.GetLocalIPv4Address(); - await _registry.RegisterAsync(dsAddress, cancellation); + await _registry.RegisterAsync(dsAddress, config.InternalAddress, cancellation); _heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); _heartbeatTask = UnityServerHeartbeatLoop.RunAsync(_registry, TimeSpan.FromSeconds(30), _heartbeatCts.Token); diff --git a/src/Game.Client/Assets/Resources/GameEnvironmentSettings.asset b/src/Game.Client/Assets/Resources/GameEnvironmentSettings.asset index 169331737..40b5b7b12 100644 --- a/src/Game.Client/Assets/Resources/GameEnvironmentSettings.asset +++ b/src/Game.Client/Assets/Resources/GameEnvironmentSettings.asset @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 9471648ad196c884282ee28e583fde6e, type: 3} m_Name: GameEnvironmentSettings m_EditorClassIdentifier: Game.Shared::Game.Shared.GameEnvironmentSettings - _environment: 0 + _environment: 1 _configs: - _environment: 0 _dependencyResolverMode: 1 @@ -32,8 +32,8 @@ MonoBehaviour: _buildRemoteCatalog: 0 - _environment: 1 _dependencyResolverMode: 1 - _apiBaseUrl: https://game-server-fywq7xza3a-an.a.run.app - _grpcBaseUrl: + _apiBaseUrl: https://game-server-954800155666.asia-northeast1.run.app + _grpcBaseUrl: https://game-realtime-954800155666.asia-northeast1.run.app _webSocketUrl: _unityServerAddress: _unityServerPort: 7777 @@ -48,8 +48,8 @@ MonoBehaviour: _buildRemoteCatalog: 1 - _environment: 1000 _dependencyResolverMode: 1 - _apiBaseUrl: https://game-server-fywq7xza3a-an.a.run.app - _grpcBaseUrl: + _apiBaseUrl: https://game-server-954800155666.asia-northeast1.run.app + _grpcBaseUrl: https://game-realtime-954800155666.asia-northeast1.run.app _webSocketUrl: _unityServerAddress: _unityServerPort: 7777 @@ -64,8 +64,8 @@ MonoBehaviour: _buildRemoteCatalog: 1 - _environment: 2000 _dependencyResolverMode: 1 - _apiBaseUrl: https://game-server-fywq7xza3a-an.a.run.app - _grpcBaseUrl: + _apiBaseUrl: https://game-server-954800155666.asia-northeast1.run.app + _grpcBaseUrl: https://game-realtime-954800155666.asia-northeast1.run.app _webSocketUrl: _unityServerAddress: _unityServerPort: 7777 @@ -80,8 +80,8 @@ MonoBehaviour: _buildRemoteCatalog: 1 - _environment: 3000 _dependencyResolverMode: 1 - _apiBaseUrl: https://game-server-fywq7xza3a-an.a.run.app - _grpcBaseUrl: + _apiBaseUrl: https://game-server-954800155666.asia-northeast1.run.app + _grpcBaseUrl: https://game-realtime-954800155666.asia-northeast1.run.app _webSocketUrl: _unityServerAddress: _unityServerPort: 7777 diff --git a/src/Game.Client/Assets/Settings/Build Profiles/Windows - Development.asset b/src/Game.Client/Assets/Settings/Build Profiles/Windows - Development.asset index ab026ccc3..ebf7f940c 100644 --- a/src/Game.Client/Assets/Settings/Build Profiles/Windows - Development.asset +++ b/src/Game.Client/Assets/Settings/Build Profiles/Windows - Development.asset @@ -222,7 +222,7 @@ MonoBehaviour: - line: '| androidSplitApplicationBinary: 0' - line: '| keepLoadedShadersAlive: 1' - line: '| StripUnusedMeshComponents: 0' - - line: '| strictShaderVariantMatching: 1' + - line: '| strictShaderVariantMatching: 0' - line: '| VertexChannelCompressionMask: 4054' - line: '| iPhoneSdkVersion: 988' - line: '| iOSSimulatorArchitecture: 0' @@ -887,7 +887,7 @@ MonoBehaviour: - line: '| PS4: DOTWEEN;UNITY_POST_PROCESSING_STACK_V2' - line: '| PS5: DOTWEEN;UNITY_POST_PROCESSING_STACK_V2' - line: '| QNX: DOTWEEN;UNITY_POST_PROCESSING_STACK_V2' - - line: '| Standalone: UNITY_PIPELINE_URP;DOTWEEN;UNITY_POST_PROCESSING_STACK_V2;UNITASK_DOTWEEN_SUPPORT;EDGEGAP_PLUGIN_SERVERS;FUSION_LOGLEVEL_INFO;FUSION_WEAVER;FUSION2;FUSION_2;FUSION_2_0;FUSION_2_0_11;FUSION_2_OR_NEWER;FUSION_2_0_OR_NEWER' + - line: '| Standalone: DEVELOP;UNITY_PIPELINE_URP;DOTWEEN;UNITY_POST_PROCESSING_STACK_V2;UNITASK_DOTWEEN_SUPPORT;EDGEGAP_PLUGIN_SERVERS;FUSION_LOGLEVEL_INFO;FUSION_WEAVER;FUSION2;FUSION_2;FUSION_2_0;FUSION_2_0_11;FUSION_2_OR_NEWER;FUSION_2_0_OR_NEWER' - line: '| VisionOS: DOTWEEN;UNITY_POST_PROCESSING_STACK_V2' - line: '| WebGL: DOTWEEN;UNITY_POST_PROCESSING_STACK_V2;UNITY_PIPELINE_URP;UNITASK_DOTWEEN_SUPPORT' - line: '| Windows Store Apps: DOTWEEN' @@ -917,7 +917,7 @@ MonoBehaviour: - line: '| PS5: 1' - line: '| QNX: 1' - line: '| ReservedCFE: 1' - - line: '| Standalone: 4' + - line: '| Standalone: 0' - line: '| VisionOS: 1' - line: '| WebGL: 1' - line: '| Windows Store Apps: 1' diff --git a/src/Game.Client/Assets/link.xml b/src/Game.Client/Assets/link.xml new file mode 100644 index 000000000..f2ad81804 --- /dev/null +++ b/src/Game.Client/Assets/link.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/Game.Client/Assets/link.xml.meta b/src/Game.Client/Assets/link.xml.meta new file mode 100644 index 000000000..c6ea9a0eb --- /dev/null +++ b/src/Game.Client/Assets/link.xml.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 12558a978e2b4604b9054c70b2bb4a5b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Game.Realtime/Program.cs b/src/Game.Realtime/Program.cs index 813e6c232..3bf1cf0da 100644 --- a/src/Game.Realtime/Program.cs +++ b/src/Game.Realtime/Program.cs @@ -1,13 +1,16 @@ +using System.Net.Security; using Game.Realtime.Extensions; using Game.Realtime.Filters; using Game.Server.Shared.Configuration; using Game.Server.Shared.Extensions; +using Google.Apis.Auth.OAuth2; using MagicOnion.Server; using Microsoft.AspNetCore.Server.Kestrel.Core; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; +using StackExchange.Redis; namespace Game.Realtime; @@ -43,9 +46,35 @@ public static async Task Main(string[] args) }); // Valkey/Redis connection - builder.Services.AddValkeyConnection(builder.Configuration); + // MagicOnion の MapMagicOnionService() がルートマッピング時に IConnectionMultiplexer を即座に解決するため、 + // ValkeyConnectionInitializer(IHostedService)パターンではなく、builder.Build() 前に同期接続を確立する。 var valkeyConnectionString = builder.Configuration.GetConnectionString("Valkey") - ?? "localhost:6379,abortConnect=false"; + ?? throw new InvalidOperationException("ConnectionStrings:Valkey is not configured."); + var valkeyOptions = ConfigurationOptions.Parse(valkeyConnectionString); + GoogleCredential? valkeyCredential = null; + + if (valkeyOptions.Ssl) + { + // GCP Memorystore IAM 認証: トークンを取得して接続 + valkeyCredential = await GoogleCredential.GetApplicationDefaultAsync(); + var token = await valkeyCredential.UnderlyingCredential.GetAccessTokenForRequestAsync(); + valkeyOptions.User = "default"; + valkeyOptions.Password = token; + valkeyOptions.CertificateValidation += (_, _, _, errors) => + errors is SslPolicyErrors.None or SslPolicyErrors.RemoteCertificateChainErrors; + } + + var valkeyMultiplexer = await ConnectionMultiplexer.ConnectAsync(valkeyOptions); + Log.Information("Connected to Valkey/Redis{IamSuffix}", valkeyOptions.Ssl ? " with IAM authentication" : ""); + + builder.Services.AddSingleton(valkeyMultiplexer); + + // IAM トークンリフレッシュ(4分間隔、トークン有効期限は1時間) + if (valkeyCredential != null) + { + var credential = valkeyCredential; + builder.Services.AddHostedService(_ => new ValkeyTokenRefreshService(valkeyMultiplexer, credential)); + } // gRPC + MagicOnion with Redis backplane builder.Services.AddGrpc(); @@ -61,8 +90,8 @@ public static async Task Main(string[] args) }) .UseRedisGroup(options => { - options.ConnectionString = valkeyConnectionString; - }); + options.ConnectionMultiplexer = valkeyMultiplexer; + }, registerAsDefault: true); // JWT Authentication builder.Services.AddJwtValidation(builder.Configuration); @@ -111,4 +140,47 @@ public static async Task Main(string[] args) await Log.CloseAndFlushAsync(); } } + + /// + /// GCP Memorystore IAM トークンを定期的にリフレッシュする BackgroundService。 + /// ValkeyConnectionInitializer 相当の機能を Game.Realtime 用に分離。 + /// + private sealed class ValkeyTokenRefreshService : BackgroundService + { + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(4); + + private readonly IConnectionMultiplexer _multiplexer; + private readonly GoogleCredential _credential; + + public ValkeyTokenRefreshService(IConnectionMultiplexer multiplexer, GoogleCredential credential) + { + _multiplexer = multiplexer; + _credential = credential; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(RefreshInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + var newToken = await _credential.UnderlyingCredential.GetAccessTokenForRequestAsync( + cancellationToken: stoppingToken); + foreach (var server in _multiplexer.GetServers()) + { + await server.ExecuteAsync("AUTH", "default", newToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to refresh Valkey IAM token"); + } + } + } + } } diff --git a/src/Game.Server/Controllers/UnityServerController.cs b/src/Game.Server/Controllers/UnityServerController.cs index 4e9d2662f..209973473 100644 --- a/src/Game.Server/Controllers/UnityServerController.cs +++ b/src/Game.Server/Controllers/UnityServerController.cs @@ -76,7 +76,10 @@ public async Task Register([FromBody] UnityServerRegistrationRequ { await _registryService.RegisterAsync(request); - _logger.LogInformation("DS registered: dsId={DsId}, address={Address}, gamePort={GamePort}", request.DsId, request.Address, request.GamePort); + _logger.LogInformation( + "DS registered: dsId={DsId}, address={Address}, gamePort={GamePort}, internalAddress={InternalAddress}", + request.DsId, request.Address, request.GamePort, + string.IsNullOrEmpty(request.InternalAddress) ? "(none)" : request.InternalAddress); return Ok(); } diff --git a/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs b/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs index 2b82bb2c3..709842338 100644 --- a/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs +++ b/src/Game.Server/Services/Interfaces/IUnityServerRegistryService.cs @@ -60,10 +60,16 @@ public class DsInfo public string DsId { get; set; } = string.Empty; /// - /// DS のアドレス(IP またはホスト名)。 + /// DS のアドレス(IP またはホスト名)。クライアント UDP 接続用の外部 IP。 /// public string Address { get; set; } = string.Empty; + /// + /// DS の VPC 内部 IP アドレス。Game.Server → DS 間の HTTP 通信(VPC Connector 経由)に使用。 + /// 未設定時は空文字列。その場合は をフォールバックとして使用する。 + /// + public string InternalAddress { get; set; } = string.Empty; + /// /// Fusion ゲームポート番号。 /// diff --git a/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs b/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs index 661f77f62..7d1323cfc 100644 --- a/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs +++ b/src/Game.Server/Services/Interfaces/IUnityServerSessionService.cs @@ -13,6 +13,7 @@ public interface IUnityServerSessionService /// 割り当てるマッチID。 /// ステージID。 /// 期待プレイヤー数。 + /// 割り当てた DS の情報。クライアントへの接続先通知に使用する。 /// 空き DS が存在しない場合。 - Task AssignSessionAsync(string matchId, int stageId, int expectedPlayers); + Task AssignSessionAsync(string matchId, int stageId, int expectedPlayers); } diff --git a/src/Game.Server/Services/UnityServerAuthService.cs b/src/Game.Server/Services/UnityServerAuthService.cs index 10befeac3..6b930be7c 100644 --- a/src/Game.Server/Services/UnityServerAuthService.cs +++ b/src/Game.Server/Services/UnityServerAuthService.cs @@ -72,15 +72,21 @@ public async Task IssueTokenAsync(string userId, string "Issued HMAC session token for user {UserId}, match {MatchId}", userId, matchId); // DS セッション割り当て(stageId が指定された場合のみ実行) + string serverAddress = string.Empty; + int serverPort = 0; if (stageId > 0) { - await _unityServerSession.AssignSessionAsync(matchId, stageId, expectedPlayers); + var dsInfo = await _unityServerSession.AssignSessionAsync(matchId, stageId, expectedPlayers); + serverAddress = dsInfo.Address; + serverPort = dsInfo.GamePort; } return new UnityServerAuthResponse { Token = token, SessionName = matchId, + ServerAddress = serverAddress, + ServerPort = serverPort, }; } diff --git a/src/Game.Server/Services/UnityServerRegistryService.cs b/src/Game.Server/Services/UnityServerRegistryService.cs index 0f60a2a48..6cda1d079 100644 --- a/src/Game.Server/Services/UnityServerRegistryService.cs +++ b/src/Game.Server/Services/UnityServerRegistryService.cs @@ -47,6 +47,7 @@ public Task RegisterAsync(UnityServerRegistrationRequest request) { DsId = request.DsId, Address = request.Address, + InternalAddress = request.InternalAddress ?? string.Empty, GamePort = request.GamePort, HealthPort = request.HealthPort, Status = "idle", @@ -66,8 +67,9 @@ public Task RegisterAsync(UnityServerRegistrationRequest request) await Task.WhenAll(tasks); _logger.LogInformation( - "DS registered in Valkey: dsId={DsId}, address={Address}:{GamePort}", - request.DsId, request.Address, request.GamePort); + "DS registered in Valkey: dsId={DsId}, address={Address}:{GamePort}, internalAddress={InternalAddress}", + request.DsId, request.Address, request.GamePort, + string.IsNullOrEmpty(info.InternalAddress) ? "(none)" : info.InternalAddress); }, _logger, nameof(RegisterAsync)); diff --git a/src/Game.Server/Services/UnityServerSessionService.cs b/src/Game.Server/Services/UnityServerSessionService.cs index b4c803f5f..ebbfcdf2d 100644 --- a/src/Game.Server/Services/UnityServerSessionService.cs +++ b/src/Game.Server/Services/UnityServerSessionService.cs @@ -37,8 +37,9 @@ public UnityServerSessionService( /// 割り当てるマッチID。 /// ステージID。 /// 期待プレイヤー数。 + /// 割り当てた DS の情報。クライアントへの接続先通知に使用する。 /// 空き DS が存在しない場合。 - public async Task AssignSessionAsync(string matchId, int stageId, int expectedPlayers) + public async Task AssignSessionAsync(string matchId, int stageId, int expectedPlayers) { // 1. DS 一覧取得(ハートビート確認済み + 死亡 DS 自動削除) var servers = await _registryService.GetAvailableServersAsync(); @@ -54,16 +55,21 @@ public async Task AssignSessionAsync(string matchId, int stageId, int expectedPl // 2. 最初の idle DS を選択 var target = servers[0]; + // Direct VPC Egress 経由で VPC 内部 IP に到達可能。InternalAddress 優先、未設定時は外部 IP にフォールバック + var dsHost = !string.IsNullOrEmpty(target.InternalAddress) ? target.InternalAddress : target.Address; + _logger.LogInformation( - "DS を選択: dsId={DsId}, address={Address}:{HealthPort}, matchId={MatchId}", - target.DsId, target.Address, target.HealthPort, matchId); + "DS を選択: dsId={DsId}, address={Address}:{HealthPort}, internalAddress={InternalAddress}, matchId={MatchId}", + target.DsId, target.Address, target.HealthPort, + string.IsNullOrEmpty(target.InternalAddress) ? "(none, fallback to address)" : target.InternalAddress, + matchId); // 3. DS に HTTP POST でセッション作成指示 var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(30); var requestBody = $"{{\"matchId\":\"{matchId}\",\"stageId\":{stageId},\"expectedPlayers\":{expectedPlayers}}}"; - var url = $"http://{target.Address}:{target.HealthPort}{SessionStartPath}"; + var url = $"http://{dsHost}:{target.HealthPort}{SessionStartPath}"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); @@ -83,7 +89,10 @@ public async Task AssignSessionAsync(string matchId, int stageId, int expectedPl await _registryService.SetStatusAsync(target.DsId, "active", matchId); _logger.LogInformation( - "セッション割り当て完了: dsId={DsId}, address={Address}:{HealthPort}, matchId={MatchId}", - target.DsId, target.Address, target.HealthPort, matchId); + "セッション割り当て完了: dsId={DsId}, url={Url}, matchId={MatchId}", + target.DsId, url, matchId); + + // 割り当てた DS 情報を返却(クライアントへの接続先動的通知に使用) + return target; } } diff --git a/src/Game.Shared/Runtime/Shared/Dto/UnityServerAuthDto.cs b/src/Game.Shared/Runtime/Shared/Dto/UnityServerAuthDto.cs index 3e4f75e94..2d5ca4e8e 100644 --- a/src/Game.Shared/Runtime/Shared/Dto/UnityServerAuthDto.cs +++ b/src/Game.Shared/Runtime/Shared/Dto/UnityServerAuthDto.cs @@ -23,5 +23,21 @@ public class UnityServerAuthResponse /// [Key(1)] public string SessionName { get; set; } = string.Empty; + + /// + /// 割り当てられた DS のアドレス(IP またはホスト名)。 + /// DS 割り当てが行われた場合(stageId > 0)に設定される。 + /// 空文字列の場合は のフォールバックを使用する。 + /// + [Key(2)] + public string ServerAddress { get; set; } = string.Empty; + + /// + /// 割り当てられた DS の Fusion ゲームポート番号。 + /// DS 割り当てが行われた場合(stageId > 0)に設定される。 + /// 0 の場合は のフォールバックを使用する。 + /// + [Key(3)] + public int ServerPort { get; set; } } } diff --git a/src/Game.Shared/Runtime/Shared/Dto/UnityServerRegistrationDto.cs b/src/Game.Shared/Runtime/Shared/Dto/UnityServerRegistrationDto.cs index 5dcf34009..0f0689ea9 100644 --- a/src/Game.Shared/Runtime/Shared/Dto/UnityServerRegistrationDto.cs +++ b/src/Game.Shared/Runtime/Shared/Dto/UnityServerRegistrationDto.cs @@ -32,5 +32,12 @@ public class UnityServerRegistrationRequest /// [Key(3)] public int HealthPort { get; set; } + + /// + /// DS の VPC 内部 IP アドレス。Game.Server → DS 間の HTTP 通信(VPC Connector 経由)に使用。 + /// 非 GCE 環境や環境変数未設定時は null。 + /// + [Key(4)] + public string InternalAddress { get; set; } } }