diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 2e3132c..cfd1ec0 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -16,6 +16,7 @@ import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; import { GameNetworkEventReceiver } from "./network/receivers/GameNetworkEventReceiver"; import { GameNetworkStateApplier } from "./network/handlers/GameNetworkStateApplier"; +import type { WorldViewport } from "@client/scenes/game/application/culling/worldViewport"; const ENABLE_DEBUG_LOG = import.meta.env.DEV; @@ -93,4 +94,9 @@ this.eventReceiver.unbind(); this.stateApplier.dispose(); } + + /** 可視矩形に基づいて画面外描画を抑制する */ + public applyViewportCulling(viewport: WorldViewport, marginPx: number): void { + this.stateApplier.applyViewportCulling(viewport, marginPx); + } } diff --git a/apps/client/src/scenes/game/application/culling/worldViewport.ts b/apps/client/src/scenes/game/application/culling/worldViewport.ts new file mode 100644 index 0000000..2ce41f4 --- /dev/null +++ b/apps/client/src/scenes/game/application/culling/worldViewport.ts @@ -0,0 +1,68 @@ +/** + * worldViewport + * ワールド座標系の可視領域計算と包含判定を提供する + */ + +/** ワールド座標系の可視矩形を表す型 */ +export type WorldViewport = { + left: number; + top: number; + right: number; + bottom: number; +}; + +/** 画面中心座標とスクリーンサイズから可視矩形を解決する */ +export const resolveWorldViewport = ( + centerX: number, + centerY: number, + screenWidth: number, + screenHeight: number, +): WorldViewport => { + const halfWidth = screenWidth / 2; + const halfHeight = screenHeight / 2; + + return { + left: centerX - halfWidth, + top: centerY - halfHeight, + right: centerX + halfWidth, + bottom: centerY + halfHeight, + }; +}; + +/** 矩形を指定マージンで外側に拡張する */ +export const expandWorldViewport = ( + viewport: WorldViewport, + marginPx: number, +): WorldViewport => { + return { + left: viewport.left - marginPx, + top: viewport.top - marginPx, + right: viewport.right + marginPx, + bottom: viewport.bottom + marginPx, + }; +}; + +/** 点座標が可視矩形内に含まれるか判定する */ +export const isPointInViewport = ( + x: number, + y: number, + viewport: WorldViewport, +): boolean => { + return x >= viewport.left + && x <= viewport.right + && y >= viewport.top + && y <= viewport.bottom; +}; + +/** 円が可視矩形と交差するか判定する */ +export const isCircleIntersectingViewport = ( + x: number, + y: number, + radiusPx: number, + viewport: WorldViewport, +): boolean => { + return x + radiusPx >= viewport.left + && x - radiusPx <= viewport.right + && y + radiusPx >= viewport.top + && y - radiusPx <= viewport.bottom; +}; diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts index 2aef873..3c47375 100644 --- a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts @@ -7,6 +7,11 @@ import { LocalPlayerController, RemotePlayerController } from "@client/scenes/game/entities/player/PlayerController"; import type { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; import type { MoveSender } from "@client/scenes/game/application/network/PlayerMoveSender"; +import { + expandWorldViewport, + isCircleIntersectingViewport, + resolveWorldViewport, +} from "@client/scenes/game/application/culling/worldViewport"; import type { LoopFrameContext, LoopFrameEffects, @@ -29,10 +34,15 @@ /** シミュレーション段の更新処理を担うステップ */ export class SimulationStep implements LoopStep { + private static readonly OFFSCREEN_REMOTE_UPDATE_INTERVAL_FRAMES = 6; + private static readonly REMOTE_PLAYER_CULL_RADIUS_PX = + config.GAME_CONFIG.PLAYER_RADIUS_PX * config.GAME_CONFIG.PLAYER_RENDER_SCALE; + private readonly moveSender: MoveSender; private readonly nowMsProvider: () => number; private lastPositionSentTime = 0; private wasMoving = false; + private frameCount = 0; constructor({ moveSender, nowMsProvider = () => performance.now() }: SimulationStepOptions) { this.moveSender = moveSender; @@ -44,6 +54,7 @@ context: Readonly, effects: LoopFrameEffects, ): void { + this.frameCount += 1; const params: SimulationStepParams = { me: context.me, playerRepository: context.playerRepository, @@ -52,9 +63,22 @@ }; this.runLocalSimulation({ me: params.me, isMoving: params.movementState.isMoving }); + + const meDisplay = params.me.getDisplayObject(); + const viewport = expandWorldViewport( + resolveWorldViewport( + meDisplay.x, + meDisplay.y, + context.app.screen.width, + context.app.screen.height, + ), + config.GAME_CONFIG.GRID_CELL_SIZE, + ); + this.runRemoteSimulation({ playerRepository: params.playerRepository, deltaSeconds: params.deltaSeconds, + viewport, }); } @@ -82,10 +106,35 @@ this.wasMoving = isMoving; } - private runRemoteSimulation({ playerRepository, deltaSeconds }: Pick) { - playerRepository.values().forEach((player) => { + private runRemoteSimulation({ + playerRepository, + deltaSeconds, + viewport, + }: Pick & { + viewport: ReturnType; + }) { + const shouldTickOffscreen = + this.frameCount % SimulationStep.OFFSCREEN_REMOTE_UPDATE_INTERVAL_FRAMES === 0; + + Object.values(playerRepository.toRecord()).forEach((player) => { if (player instanceof RemotePlayerController) { - player.tick(deltaSeconds); + const display = player.getDisplayObject(); + const isVisible = isCircleIntersectingViewport( + display.x, + display.y, + SimulationStep.REMOTE_PLAYER_CULL_RADIUS_PX, + viewport, + ); + display.visible = isVisible; + + if (!isVisible && !shouldTickOffscreen) { + return; + } + + const tickDeltaSeconds = isVisible + ? deltaSeconds + : deltaSeconds * SimulationStep.OFFSCREEN_REMOTE_UPDATE_INTERVAL_FRAMES; + player.tick(tickDeltaSeconds); } }); } diff --git a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts index 2fe73cd..2f2a8da 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -27,6 +27,7 @@ import { MapSyncHandler } from "./MapSyncHandler"; import { PlayerSyncHandler } from "./PlayerSyncHandler"; import type { ReceivedGameEventHandlers } from "../receivers/GameNetworkEventReceiver"; +import type { WorldViewport } from "@client/scenes/game/application/culling/worldViewport"; /** 状態反映処理の初期化入力 */ export type GameNetworkStateApplierOptions = { @@ -106,6 +107,11 @@ this.hurricaneSyncHandler.destroy(); } + /** 可視矩形に基づいて画面外ハリケーンの描画を抑制する */ + public applyViewportCulling(viewport: WorldViewport, marginPx: number): void { + this.hurricaneSyncHandler.applyViewportCulling(viewport, marginPx); + } + /** 戦闘イベント橋渡しハンドラを生成する */ private createCombatSyncHandler({ onRemoteBombPlaced, diff --git a/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts index 0196319..302cba3 100644 --- a/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts +++ b/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts @@ -9,6 +9,7 @@ UpdateHurricanesPayload, } from "@repo/shared"; import { HurricaneOverlayController } from "@client/scenes/game/entities/hurricane/HurricaneOverlayController"; +import type { WorldViewport } from "@client/scenes/game/application/culling/worldViewport"; /** HurricaneSyncHandler の初期化入力 */ export type HurricaneSyncHandlerOptions = { @@ -35,6 +36,11 @@ this.overlayController.applyUpdates(payload); }; + /** 可視矩形に基づいてハリケーン表示を切り替える */ + public applyViewportCulling(viewport: WorldViewport, marginPx: number): void { + this.overlayController.applyViewportCulling(viewport, marginPx); + } + /** 管理中リソースを破棄する */ public destroy(): void { this.overlayController.destroy(); diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 0aeb79c..0296bfc 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -22,6 +22,10 @@ import type { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; import { config } from "@client/config"; import type { PongPayload } from "@repo/shared"; +import { + expandWorldViewport, + resolveWorldViewport, +} from "@client/scenes/game/application/culling/worldViewport"; type RuntimeLifecycleState = "created" | "initialized" | "destroyed"; @@ -199,6 +203,7 @@ } this.gameLoop?.tick(ticker); + this.applyViewportCulling(); } /** チームごとの塗り率配列を返す */ @@ -259,4 +264,26 @@ } this.disposableRegistry.disposeAll(); } + + /** ローカルプレイヤー中心の可視矩形で画面外描画を抑制する */ + private applyViewportCulling(): void { + const me = this.playerRepository.getById(this.myId); + if (!me) { + return; + } + + const meDisplay = me.getDisplayObject(); + const viewport = expandWorldViewport( + resolveWorldViewport( + meDisplay.x, + meDisplay.y, + this.app.screen.width, + this.app.screen.height, + ), + config.GAME_CONFIG.GRID_CELL_SIZE, + ); + + this.bombManager?.applyViewportCulling(viewport, config.GAME_CONFIG.GRID_CELL_SIZE); + this.networkSync?.applyViewportCulling(viewport, config.GAME_CONFIG.GRID_CELL_SIZE); + } } diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index d883146..1892d6e 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -9,6 +9,7 @@ BombPlacedPayload, PlaceBombPayload, } from "@repo/shared"; +import type { WorldViewport } from "@client/scenes/game/application/culling/worldViewport"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; import { BombIdRegistry } from "./BombIdRegistry"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; @@ -122,6 +123,11 @@ this.bombRuntimeSystem.tick(this.getElapsedMs()); } + /** 可視矩形に基づいて爆弾表示を切り替える */ + public applyViewportCulling(viewport: WorldViewport, marginPx: number): void { + this.bombRepository.applyViewportCulling(viewport, marginPx); + } + /** 管理中の爆弾をすべて破棄する */ public destroy(): void { this.bombRepository.clear(); diff --git a/apps/client/src/scenes/game/entities/bomb/runtime/BombRepository.ts b/apps/client/src/scenes/game/entities/bomb/runtime/BombRepository.ts index a3f557c..993b364 100644 --- a/apps/client/src/scenes/game/entities/bomb/runtime/BombRepository.ts +++ b/apps/client/src/scenes/game/entities/bomb/runtime/BombRepository.ts @@ -4,8 +4,13 @@ * 追加,更新,削除,破棄の基本操作を提供する */ import type { Container } from "pixi.js"; +import { config } from "@client/config"; import { BombController } from "@client/scenes/game/entities/bomb/BombController"; import { BombIdRegistry } from "@client/scenes/game/entities/bomb/BombIdRegistry"; +import { + isCircleIntersectingViewport, + type WorldViewport, +} from "@client/scenes/game/application/culling/worldViewport"; /** 爆弾の描画更新に使う入力データ型 */ export type BombRenderPayload = { @@ -80,6 +85,26 @@ }); } + /** 可視矩形に基づいて爆弾表示を切り替える */ + public applyViewportCulling(viewport: WorldViewport, marginPx: number): void { + this.bombs.forEach((bomb, bombId) => { + const payload = this.bombRenderPayloadById.get(bombId); + if (!payload) { + return; + } + + const display = bomb.getDisplayObject(); + const radiusPx = payload.radiusGrid * config.GAME_CONFIG.GRID_CELL_SIZE + marginPx; + const isVisible = isCircleIntersectingViewport( + display.x, + display.y, + radiusPx, + viewport, + ); + display.visible = isVisible; + }); + } + /** 管理中の爆弾をすべて破棄する */ public clear(): void { this.bombs.forEach((bomb) => bomb.destroy()); diff --git a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts index 383005a..9239663 100644 --- a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts +++ b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts @@ -7,6 +7,10 @@ import { config } from "@client/config"; import { Container, Sprite, Texture } from "pixi.js"; import { loadHurricaneTexture } from "./HurricaneTextureCache"; +import { + isCircleIntersectingViewport, + type WorldViewport, +} from "@client/scenes/game/application/culling/worldViewport"; type HurricaneDisplay = { container: Container; @@ -65,6 +69,20 @@ this.applyUpdates(states); } + /** 可視矩形に基づいてハリケーン表示を切り替える */ + public applyViewportCulling(viewport: WorldViewport, marginPx: number): void { + this.displayById.forEach((display) => { + const radiusPx = display.radiusGrid * config.GAME_CONFIG.GRID_CELL_SIZE + marginPx; + const isVisible = isCircleIntersectingViewport( + display.container.x, + display.container.y, + radiusPx, + viewport, + ); + display.container.visible = isVisible; + }); + } + /** 描画リソースを破棄する */ public destroy(): void { this.displayById.forEach((display) => {