3Dブラウザベースのフットボールをどのように書いたか。 パート2

こんにちは、Habr!



それで、ブラウザベースの3Dフットボールを書くことについての私の話の続きが間に合いました。 長い休憩をおaびします。欠点は仕事、ボルシチの生産、愛する夫のために食べられる他のもの、修理の負担、その他すべてです。 しかし、記事はそれ自体を書いたり読んだりしません。 したがって、すべての最初の部分に興味があり、まだ忘れていません-あなたは猫を歓迎しています。







念のため、最初の部分へのリンク-3Dブラウザベースのサッカーを書いたとき。 パート1



それで、前の部分はサッカー場とゴールで終わった。 これは、本格的なサッカーアクションにはカテゴリー的に十分ではありません。 人々はサッカー選手を必要とします。 これから始めますが、最初にエラーに取り組みます。



エラー処理



最初の部分へのコメントでは、視点の違反があったことが正しく指摘されました。 私もこれに気づきましたが、Three.js自体の不完全さに起因します。

ただし、 THREE.PerspectiveCameraメソッドのパラメーターで遊んだ後、消化可能な結果を​​達成することができました。



宛先:



this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
      
      









後:



 this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
      
      









すなわち 問題は最初の引数にありました。これはカメラの垂直視野の原因であり、度で設定されます。 しかし、このメソッドの呼び出しは、Three.jsの公式チュートリアル-75が渡されるシーンの作成からコピーされていることに注意してください。



サッカー選手-統計



しかし、フットボール選手の創造に戻ります。 前のパートでは、抽象FootballObjectクラスについて説明しました。 Playerクラスを継承するのは彼からです。



Player.ts



 import { Field } from './field'; import { FootballObject } from './object'; import { BASE_URL } from './const'; import { Mesh, MeshBasicMaterial, Scene, Texture } from 'three'; export enum PlayerType { DEFENDER, MIDFIELDER, FORWARD } export interface IPlayerOptions { isCpu: boolean; } const MIN_SPEED = 0.03; const MAX_SPEED = 0.07; export class Player extends FootballObject { protected options: IPlayerOptions; protected mesh: Mesh; protected type: PlayerType; protected startX: number; protected startZ: number; protected targetX: number; protected targetZ: number; protected speed: number; public isActive = true; public isRun = false; public isCurrent = false; static ready: Promise<any>; static mesh: Mesh; constructor(scene: Scene, options: IPlayerOptions) { super(scene); this.options = options; this.speed = (Math.random() * (MAX_SPEED - MIN_SPEED) + MIN_SPEED); } static init(scene: Scene): Promise<any> { if (!Player.ready) { Player.ready = new Promise((resolve, reject) => { const loader = new THREE.SEA3D({ autoPlay: true, container: scene, multiplier: .6 }); loader.onComplete = () => { const mesh: Mesh = loader.getMesh('Player'); const hat = loader.getMesh('Hat'); if (hat) { hat.visible = false; } mesh.scale.set(.02, .02, .02); mesh.position.y = 2; mesh.visible = false; Player.mesh = mesh; resolve(); }; loader.load(`${ BASE_URL }/resources/models/player1.sea`); }); } return Player.ready; } }
      
      





フットボール選手のタイプが最初に発表されるのはPlayerTypeです:







次に、オプションのインターフェースが決定されます。この場合、単一のフィールド( IsCpu)で構成され、このプレーヤーがコンピューターによって制御されるチームのメンバーであるかどうかが決まります。



Playerクラスのメンバーについて少し:





ここには2つの興味深いポイントがあります。



最初の瞬間は、モデルのロードが静的メソッドで実行されることです。 最初は、各プレーヤーについて、キャッシュされたままで、各プレーヤーのネットワークトリップの形でオーバーヘッドが発生しなかったため、自分のモデルをダウンロードしました。 そして、各プレイヤーが自分のモデルをインスタンス化したという事実は、それがより多くのメモリを消費することを除いて、特に怖いものではありませんでしたが、それと一緒に暮らすことはかなり可能でした。 しかし、私には明らかでない理由により、作成されたPlayerクラスの各インスタンスは、すべてのインスタンスをますます変色させました。 はい、すべてのコピーを作成した後、テクスチャがないように完全に白になりました。







さまざまなダウンロードオプション、さまざまなテクスチャのインストール方法を試しましたが、すべて無駄でした。 そして、ステージで繰り返し使用する場合にモデルを使用して作業を最適化する方法について読んだことを思い出しました。 毎回モデルをロードするのではなく、クローンを作成することをお勧めします。これにより、モデルの保存に割り当てられるメモリの量を減らすことができます。 Three.jsにこのような機会があるかどうかを調べ始めたところ、-Mesh (クローンメソッドを参照)があることがわかりました

Playerのcloneメソッドに追加します。



 clone() { this.mesh = Player.mesh.clone(); this.scene.add(this.mesh); }
      
      





そして、見よ、モデルは「変色」を止めた-問題は解決した。



2番目の瞬間 -このコードを見て



 const hat = loader.getMesh('Hat'); if (hat) { hat.visible = false; }
      
      





「帽子? 帽子? 何の帽子?」 そして、あなたは正しいでしょう、帽子はここでまったく場違いです。 しかし、彼女がここから来た場所を説明します。 この記事の最初の部分でさえ、3Dモデルを見つけることの難しさについて、少し義にかなった怒りが私から出てきました。 特に、私はフットボール選手のモデルを非常に長い間検索し、Three.jsでサポートされていない形式で発見し、コンバーターを探し、変換し、ステージにアップロードしました。それは、変換中か、モデルが「壊れ」ており、おそらく今ではない可能性があることが判明しましたサッカー選手、そして巨大なカマキリ。



適切なモデルが見つかったとき、私の喜びはどこにもありませんでしたが、どこかではなく、Three.js自体の例( loader / sea3d / skinning)のセクションで。 これは、モデルが保証されていることを意味します。登録とSMSがなくても、Three.jsシーンにアップロードでき、コンバーターによって生成されたアーティファクトを受信できません。 しかし、これらすべてに重要なニュアンスがありました-サッカー選手は巨大な麦わら帽子をかぶっていました:











幸いなことに、上記のコードからわかるように、帽子は隠されていて、普通のフットボール選手であることが判明しました。



チームの色が異なるためには、プレーヤーはモデルがロードされているテクスチャとは異なるテクスチャを設定できる必要があります。 これらの目的のために、 setTextureメソッドを使用します



 setTexture(textureName: string) { const loader = new THREE.TextureLoader(); loader.load(`${ BASE_URL }/resources/textures/${textureName}`, (texture: Texture) => { this.mesh.material = this.mesh.material.clone(); texture.flipY = false; (<MeshBasicMaterial> this.mesh.material).map = texture; }); }
      
      





サッカー選手-ダイナミクス



プレイヤーを走らせましょう! このフレーズは、ロシアのサッカー代表チームのコーチが話すことができますが、私はそれを発音します。 したがって、私のプレーヤーは保証付きで走りますが、私たちのチームではそう簡単ではありません。



私の場合、すべてのサッカー選手は2つのタイプに分けられます。





ユーザーによって制御されるフットボール選手では、すべてが明確です-彼はそこに移動し、ユーザーが彼に言った場所と時間。 ユーザーが非アクティブの場合、サッカー選手は静止します。



残りのプレーヤーは、いつどこに移動するかを示す必要があります。 これは、 moveToおよびanimateの 2つのメソッドによって実装されます。 最初はプレーヤーが移動する必要があるポイントの座標を保存し、2番目はこの移動を実装し、シーンの再描画とともに呼び出されます。



 moveTo(x: number, z: number) { this.startX = this.mesh.position.x; this.startZ = this.mesh.position.z; this.targetX = x; this.targetZ = z; this.isRun = true; } animate(options: any) { if (this.isCurrent && this.isRun && !this.options.isCpu) { this.run(); } else if (this.isRun) { const distanceX = this.targetX - this.startX; const distanceZ = this.targetZ - this.startZ; const newX = this.mesh.position.x + this.speed * (distanceX > 0 ? 1 : -1); const newZ = this.mesh.position.z + this.speed * (distanceZ > 0 ? 1 : -1); let isRun = false; if (Field.isInsideByX(newX) && ((distanceX > 0 && this.mesh.position.x < this.targetX) || (distanceX < 0 && this.mesh.position.x > this.targetX))) { this.mesh.position.x = newX; isRun = true; } if (Field.isInsideByZ(newZ) && ((distanceZ > 0 && this.mesh.position.z < this.targetZ) || (distanceZ < 0 && this.mesh.position.z > this.targetZ))) { this.mesh.position.z = newZ; isRun = true; } this.isRun = isRun; this.run(); } else if (!options.isStarted) { this.idleStatic(); } else { this.idleDynamic(); } }
      
      





アニメーションでは、プレーヤーの速度を考慮して、再描画の現在の反復の座標が計算され、プレーヤーがフィールドから出て、必要なアニメーションが実行中か待機中かを確認します。



なしでは生きていけないチーム



これでプレイヤーができたので、最終的にそれらからチームを編成できます。



 import { Player, PlayerType } from './player'; import { FIELD_WIDTH, FIELD_HEIGHT } from './field'; import { Utils } from './utils'; import { Scene } from 'three'; import { FootballObject } from './object'; export class Team { protected scene: Scene; protected options: ITeamOptions; protected players: Player[] = []; protected currentPlayer: Player; protected score = 0; withBall = false; }
      
      





プレーヤー配列には、チームのすべてのプレーヤーが含まれます。

currentPlayer-現在アクティブなプレーヤーへのリンク。

スコア -チームによって得点されたゴールの数。

withBall-チームがボールを所有しているかどうかを決定するフラグ。



私たちのチームは4-4-2スキームに従ってプレーします:



 protected getPlayersType(): PlayerType[] { return [ PlayerType.DEFENDER, PlayerType.DEFENDER, PlayerType.DEFENDER, PlayerType.DEFENDER, PlayerType.MIDFIELDER, PlayerType.MIDFIELDER, PlayerType.MIDFIELDER, PlayerType.MIDFIELDER, PlayerType.FORWARD, PlayerType.FORWARD ] }
      
      





つまり 4人のディフェンダー、4人のミッドフィールダー、2人のフォワード。



チームにサッカー選手を入れましょう:



 createPlayers() { return new Promise((resolve, reject) => { Player.init(this.scene) .then(() => { const types: PlayerType[] = this.getPlayersType(); let promises = []; for (let i = 0; i < 10; i++) { let player = new Player(this.scene, { isCpu: this.options.isCpu }); const promise = player.clone() .then(() => { if (this.options.side === 'left') { player.setRotateY(90); } else { player.setRotateY(-90); } player.setType(types[i]); player.show(); this.players.push(player); }); promises.push(promise); } Promise.all(promises) .then(() => this.setStartPositions()); resolve(); }); }); }
      
      





ここで、チームが占めるフィールドの側面に応じて(オプションで左/右が渡されます)、プレーヤーは相手のゴールに向かって90度または-90度回転します。



すべてのインスタンスの作成が完了すると、 setStartPositionsメソッドが呼び出され 、プレーヤーが開始位置に配置されます。



 setStartPositions() { const startPositions = this.getStartPositions(); this.players.forEach((item: Player, index: number) => { item.isRun = false; if (startPositions[index]) { item.setPositionX(startPositions[index].x); item.setPositionZ(startPositions[index].z); } }) } protected getStartPositions() { const halfFieldWidth = FIELD_WIDTH / 2; const halfFieldHeight = FIELD_HEIGHT / 2; if (this.options.side === 'left') { return [ { x: this.getRandomPosition(- halfFieldWidth * .6), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1) }, { x: this.getRandomPosition(- halfFieldWidth * .6), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4) }, { x: this.getRandomPosition(- halfFieldWidth * .6), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7) }, { x: this.getRandomPosition(- halfFieldWidth * .6), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9) }, { x: this.getRandomPosition(- halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1) }, { x: this.getRandomPosition(- halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4) }, { x: this.getRandomPosition(- halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7) }, { x: this.getRandomPosition(- halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9) }, { x: this.getRandomPosition(- halfFieldWidth * .2), z: 0 }, { x: 0, z: 0 } ]; } else { return [ { x: this.getRandomPosition(halfFieldWidth * .6), z: - halfFieldHeight + FIELD_HEIGHT * .1 }, { x: this.getRandomPosition(halfFieldWidth * .6), z: - halfFieldHeight + FIELD_HEIGHT * .4 }, { x: this.getRandomPosition(halfFieldWidth * .6), z: - halfFieldHeight + FIELD_HEIGHT * .7 }, { x: this.getRandomPosition(halfFieldWidth * .6), z: - halfFieldHeight + FIELD_HEIGHT * .9 }, { x: this.getRandomPosition(halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1) }, { x: this.getRandomPosition(halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4) }, { x: this.getRandomPosition(halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7) }, { x: this.getRandomPosition(halfFieldWidth * .4), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9) }, { x: this.getRandomPosition(halfFieldWidth * .2), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .3) }, { x: this.getRandomPosition(halfFieldWidth * .2), z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7) }, ]; } }
      
      





ご覧のとおり、 getRandomPositionメソッドによって実装されたランダム性の要素は、フィールド上の位置を決定するために使用されます。



 protected getRandomPosition(n: number, size?: number): number { size = size || 2; const min = n - size; const max = n + size; return Math.random() * (max - min) + min; } protected getRandomPositionX(x: number, size?: number) { let position = this.getRandomPosition(x, size); position = Math.min(position, FIELD_WIDTH / 2); position = Math.max(position, - FIELD_WIDTH / 2); return position; } protected getRandomPositionZ(z: number, size?: number) { let position = this.getRandomPosition(z, size); position = Math.min(position, FIELD_HEIGHT / 2); position = Math.max(position, - FIELD_HEIGHT / 2); return position; }
      
      





getRandomPositionXメソッドgetRandomPositionZメソッドは、フィールドのサイズを考慮に入れて、xおよびz座標のランダム要素を持つ位置を返します。これは後で使用されます。



さらに、これらの2つの方法は将来的に便利になります。



 getNearestPlayer(point: FootballObject): Player { let min: number = Infinity, nearest: Player = null; this.players.forEach((item: Player) => { if (item !== point && item.isActive) { const distance = Utils.getDistance(item, point); if (distance < min) { min = distance; nearest = item; } } }); return nearest; } getNearestForwardPlayer(point: FootballObject): Player { let min: number = Infinity, nearest: Player = null; this.players.forEach((item: Player) => { if (item !== point && item.isActive && item.getPositionX() > point.getPositionX()) { const distance = Utils.getDistance(item, point); if (distance < min) { min = distance; nearest = item; } } }); return nearest || this.getNearestPlayer(point); }
      
      





getNearestPlayer-指定されたポイントに最も近いサッカー選手を返します。

getNearestForwardPlayer-同じことを行いますが、1つ注意点があります-指定されたポイントのX座標を超えるX座標を持つサッカー選手の中から最も近いサッカー選手が検索されます。 このメソッドは、パスするフットボール選手を探すときに便利です。 さらに、そのようなプレーヤーが見つからない場合(つまり、X座標上の特定のポイントがチームのすべてのプレーヤーを超える場合)、X座標が特定のポイントのX座標よりも小さい場合でも、最も近いプレーヤーが見つかります。



距離を決定するには、 Utils.getDistanceメソッドを使用します



 static getDistance(obj1: FootballObject, obj2: FootballObject): number { if (obj1 && obj2) { const distanceX = obj1.getPositionX() - obj2.getPositionX(); const distanceZ = obj1.getPositionZ() - obj2.getPositionZ(); return Math.sqrt(distanceX * distanceX + distanceZ * distanceZ); } }
      
      





最高の防御は攻撃です



チーム戦略について考える時が来ました。 複雑にならないように、チームは攻撃または防御しかできないと決めました。 防御攻撃の 2つの戦略があります。 あるチームがボールを持っている場合-攻撃し、相手チームが防御し、その逆も同様です。 同時に、各タイプのフットボール選手は、フィールド内の特定のポイントに努力する必要があります(もちろん、偶然の要素がないわけではありません)。



たとえば、チームが攻撃するとき、ディフェンダーはフィールドの中央に、ミッドフィールダーは対戦相手のフィールドの半分(フィールドの3/4)の中央に向かい、対戦相手のゴールに近づくように攻撃します。

そして、ほぼ同じ割合で、プレイヤーは防御中にフィールドの半分に退却します。ディフェンダーはゴールを目指して努力し、フィールドの半分の中央のミッドフィールダー(フィールドの1/4)、攻撃者はフィールドの中央に残ります。



このロジックは、 TeamクラスのsetStrategyメソッドで実装されます。



 setStrategy(strategy: string) { const isLeft = this.options.side === 'left'; const RND_SIZE = 4; switch (strategy) { case 'defense': this.players .filter((item: Player) => item.getType() === PlayerType.FORWARD && item !== this.currentPlayer) .forEach((item: Player) => { item.moveTo(this.getRandomPositionX(0, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE)); }); this.players .filter((item: Player) => item.getType() === PlayerType.MIDFIELDER && item !== this.currentPlayer) .forEach((item: Player) => { item.moveTo(this.getRandomPositionX(((isLeft ? - FIELD_WIDTH : FIELD_WIDTH) / 2) * .4, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE)); }); this.players .filter((item: Player) => item.getType() === PlayerType.DEFENDER && item !== this.currentPlayer) .forEach((item: Player) => { item.moveTo(this.getRandomPositionX(((isLeft ? - FIELD_WIDTH : FIELD_WIDTH) / 2) * .6, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE)); }); break; case 'attack': this.players .filter((item: Player) => item.getType() === PlayerType.FORWARD && item !== this.currentPlayer) .forEach((item: Player) => { item.moveTo(this.getRandomPositionX(((isLeft ? FIELD_WIDTH : - FIELD_WIDTH) / 2) * .7, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE)); }); this.players .filter((item: Player) => item.getType() === PlayerType.MIDFIELDER && item !== this.currentPlayer) .forEach((item: Player) => { item.moveTo(this.getRandomPositionX(((isLeft ? FIELD_WIDTH : - FIELD_WIDTH) / 2) * .5, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE)); }); this.players .filter((item: Player) => item.getType() === PlayerType.DEFENDER && item !== this.currentPlayer) .forEach((item: Player) => { item.moveTo(this.getRandomPositionX(0, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE)); }); break; } }
      
      





これについては、おそらく、「自家製」サッカーに関する記事の第2部を完成させます。



3番目の(そして最後の)パートでは、ゲームの仕組み、コントロールインターフェイスについて説明し、ボールを追加してゴールを決めます。



そして、最後まで陰謀を台無しにしないようにするために、この記事の最後の部分でソースとデモを投稿することを思い出してください。



ご清聴ありがとうございました!



All Articles