[前記事 2.詳細設計] (https://qiita.com/miyatahirotaka/items/6967d28b4cdd7f15839c)
前回までで大雑把に設計が完了した。
実装を進めたいがどこから入るか。クライアント側UI作成が一番心理的に不安が強い。そこから着手する。
今回はここまで進捗した。
「Lobby」から「Room」に入室し、2つの「Puyo(ぷよ)」から構成される「Tsumo(ツモ)」を操作し「Field」にPuyoを置いていくことができる所まで。
以下順番にやったことを解説する。
やったこと1.SVGタグを使った静的なUI実装
画面デザインとSVGコーディング。
Lobby画面
対戦ルームが8個ある仕様にした。
長方形rectとtextのセットを8Room分並べた。
対戦Room画面
1P,2Pそれぞれのフィールド・Nextツモ・おじゃまぷよ予告のレイアウト配置決めながらSVGタグを書いた。
やったこと2.オレオレゲームエンジンを作る
対戦中はぷよぷよを動かして置いたり、連鎖してぷよが消えたり、おじゃまぷよが落ちてきたり、と動きが多い(ゲームなので当然)ので、それらもきちんと管理した上でSVGタグの描画ができるようにしておかないと開発が手詰まりになってしまうことは目に見えている。
きちんと管理するために、
https://www.webprofessional.jp/quick-tip-game-loop-in-javascript/
https://itnext.io/build-a-snake-game-in-typescript-8bee5b9f1ec6
を参考にしたのと、UnityやReact.jsを少し触った経験があるので真似して、オレオレ超簡易ゲームエンジンを作ることにした。
オレオレゲームエンジンでは、以下の設計思想/ルールにする。
・ 「Lobby画面」と「対戦Room画面」はそれぞれ"Scene"として扱う
・ "Scene"や「Lobby画面のRoomボタン」「ぷよぷよのフィールド」「1つ1つのぷよ」...
など全ての部品は"GameObject"として扱う
・"GameObject"はchildren、すなわち0〜N個の子GameObjectを持つ
・"GameObject"はrender()メソッドにより、自身のstateからSVGタグを出力し描画
・そのrender()メソッドはchildren(=子GameObject達)のrender()も呼び出す
・メインループにより30ミリ秒ごとに、現在のSceneのrender()メソッドが呼び出される
・描画処理を減らすために、自身のstateが前回render()して以降
更新されていない場合render()メソッドは何もしない
・アプリケーションは常にいずれか1つのSceneを表示する
また、キーボードからの入力受け付けについては上記URL先の実装方法と同様にした。
やったこと3.オレオレゲームエンジンのルールに基づいてSVGを描画する
上記のゲームエンジンにより、オンライン対戦ぷよぷよのクライアントサイドは、具体的に以下の流れ、仕組みにより動作することになる。
一番最初はデフォルトのSceneであるLobby Scene呼び出される。
→「LobbyScene.children=8個のRoom入室ボタンGameObject」なので、
8つそれぞれのrender()メソッドも呼び出され、Room入室ボタンのSVGタグが描画される。
→例えばキーボード操作により入室ボタンを押すと、「対戦Room Scene」に切り替わり、
そのchildrenであるフィールド・Nextツモなどが描画される
→例えば対戦が始まったとして、プレイヤーがツモ(動かせるぷよ)を動かすために
キーボード操作をすると、30ミリ秒ごとにツモの座標情報が変化する。
座標情報が変化すると出力されるSVGタグのx,y値が変わるため、ツモが動いて見える。
→例えば対戦中に、WebSocket通信により最新の相手フィールド情報を受け取ると、
30ミリ秒以内にそれが描画される
最後に、実装したTSソースコードの主要部分を抜粋する。↓↓
- オレオレゲームエンジンのコアを成すGameObject抽象クラスの一部
export abstract class GameObject implements IDrawable {
public x: number // SVGタグ用x座標
public y: number // SVGタグ用y座標
children: GameObject[] // 子GameObjectリスト
private shouldDraw: boolean //再描画すべきか?フラグ
protected state: { [key: string]: any } = {}
constructor(x: number, y: number) {
this.x = x
this.y = y
this.shouldDraw = true
this.children = []
}
public setState(key: string, value: any): void {
this.state[key] = value
this.shouldDraw = true // setState()実行後しか再描画しない
}
protected tick (): void {
// 30ミリ秒ごとに実行、継承先実装で必要な処理があれば書く
}
public draw (): void {
this.tick()
if (this.shouldDraw) {
// 再描画。toSVGElement()がSVGタグを返す
let elm = this.toSVGElement()
if (this.DOMID) {
let removingElm = document.getElementById(this.DOMID);
if (removingElm) {
removingElm.parentNode!.removeChild(removingElm);
}
}
if (elm) {
Main.svgElelemt.insertAdjacentElement("beforeend", elm)
this.svgElement = elm
}
}
for (let drawable of this.children) {
// childrenも描画
drawable.draw()
}
this.shouldDraw = false // 次のstate更新まで描画処理されないように
}
- アプリケーションのエントリーポイントになるMainクラスの一部
export class Main {
public static svgElelemt: SVGElement
public static currentSceneType: SceneType
public static Scenes: Record<SceneType, IDrawable>
public static start(): void {
// このアプリケーションにはSceneが2つある、最初はLobbyから。
Main.Scenes = {
"lobby": (new Lobby()),
"room": (new Room()),
}
Main.currentSceneType = SceneType.Lobby
window.setInterval(() => {
// メインループで30msごとに描画&キー入力受け付け
Main.draw()
for (let gamekeyStr in this.buttonStatus) {
let gamekey = gamekeyStr as GameKey
if (this.buttonStatus[gamekey]) {
this.buttonStatusSecond[gamekey]++
} else {
this.buttonStatusSecond[gamekey] = 0
}
}
}, 30)
}
private static draw (): void {
// Scene描画→children全ての描画の始まりはここから
Main.Scenes[Main.currentSceneType].draw()
}
public static changeScene(toScene: SceneType): void {
// Room入退室時に、"lobby"←→"room"間でSceneが切り替わる
Main.Scenes[Main.currentSceneType].delete()
Main.currentSceneType = toScene
}
}
- 左右下とキーボード入力すると動き、ZXキーで回転するツモ(Tsumo)GameObject実装
export class Tsumo extends GameObject {
private initx: number
private inity: number
private field: Field
private tsumoStatus: TsumoStatus
private puyos: [Puyo, Puyo] // ツモは2つのPuyoから構成される
public state: { "coordinates": [Coordinate, Coordinate], "direction": TsumoDirection } = {
coordinates: [{ x: 2, y: 1 }, { x: 2, y: 0 }],
direction: TsumoDirection.UP
}
constructor(x: number, y: number, field: Field) {
super(x, y)
this.initx = x
this.inity = y
this.field = field
this.tsumoStatus = TsumoStatus.BEING_OPERATED
this.puyos = [
new Puyo(x, y, "Blue", 'tsumo-' + field.playerType + '-0'),
new Puyo(x, y, "Red", 'tsumo-' + field.playerType + '-1')
]
this.DOMID = 'tsumo-' + field.playerType + '-' + x + '-' + y
// Tsumo自身は何も描画せず、そのchildrenのPuyoがぷよを描画する。
this.children = [
this.puyos[0],
this.puyos[1],
]
}
protected tick() {
// 自分のTsumoだけを操作する。2PのTsumoは操作しないよう。
if (this.field.playerType !== PlayerType.Player1P) {
return
}
// 下移動。一番下までついたら、ぷよが置かれたということなのでthis.placePuyo()実行
if (Main.buttonStatusSecond.DOWN % 2 === 1) {
let isMovables = this.move({ x: 0, y: 1})
if (!isMovables[0] || !isMovables[1]) {
this.placePuyo()
}
}
// 左右移動
if (Main.buttonStatusSecond.LEFT % 4 === 1) {
this.move({ x: -1, y: 0})
}
if (Main.buttonStatusSecond.RIGHT % 4 === 1) {
this.move({ x: 1, y: 0})
}
// 回転
if (Main.buttonStatusSecond.BUTTON_A % 4 === 1) {
this.spin(1)
}
if (Main.buttonStatusSecond.BUTTON_B % 4 === 1) {
this.spin(-1)
}
}
private move(m: Coordinate): [boolean, boolean] {
// 左右下キー入力時ぷよが動く。略
}
private spin(spinDir: number) {
// ZXキー入力時ぷよが回る。略
}
private placePuyo(): void {
let event = new CustomEvent('placedPuyo', {
bubbles: true,
detail: {
coordinates: this.state.coordinates,
puyos: this.puyos,
}
});
// ツモが置かれたら次のツモのためにフィールド上部に戻る
this.setState('coordinates', [{ x: 2, y: 1 }, { x: 2, y: 0 }])
this.setState('direction', TsumoDirection.UP)
// 「ぷよが置かれた」こと(event)をFieldに通知するため
document.body.dispatchEvent(event)
}
}
- 置かれたぷよ情報やツモをchildrenに持つ、Field GameObjectの一部
export class Field extends GameObject {
private tsumo: Tsumo
public playerType: PlayerType
public placedPuyos: Puyo[][]
constructor (x: number, y: number, playerType: PlayerType) {
super(x, y)
this.playerType = playerType
this.placedPuyos = new Array(6)
this.tsumo = new Tsumo(x, y, this)
// childrenはTsumoや6*13マスの中に置かれたぷよ
this.children = [
this.tsumo,
]
for (let cx = 0; cx < 6; cx++) {
this.placedPuyos[cx] = []
for (let cy = 0; cy < 13; cy++) {
let puyox = this.x + 20 * cx + 10
let puyoy = this.y + 20 * (cy - 1) + 10
this.placedPuyos[cx][cy] = new Puyo(puyox, puyoy, "None", 'puyo-' + this.playerType + '-' + cx + '-' + cy )
this.children.push(this.placedPuyos[cx][cy])
}
}
// ぷよが置かれたことが通知されたら、onPlacedPuyo(event)呼び出される
if (this.playerType === PlayerType.Player1P) {
document.addEventListener('placedPuyo', ((event: CustomEvent) => {
this.onPlacedPuyo(event)
}) as EventListener)
}
}
private onPlacedPuyo(e: CustomEvent): void {
// どこに何色のぷよが置かれたかの情報からフィールド情報更新
let coordinates = e.detail.coordinates
let isMovables = [true, true]
let puyos = e.detail.puyos
this.placedPuyos[coordinates[0].x][coordinates[0].y].setState('color', puyos[0].color)
this.placedPuyos[coordinates[1].x][coordinates[1].y].setState('color', puyos[1].color)
}
protected toSVGElement (): SVGElement | null {
// 実際にSVGタグの描画はこんな感じ。このクラスはフィールドの背景だけを返す。
let xml = `
<g xmlns="http://www.w3.org/2000/svg">
<rect x="${this.x}" y="${this.y}" width="120" height="240" stroke="#006" stroke-width="2" fill="#112" />
</g>`
let parser = new DOMParser();
let doc = parser.parseFromString(xml, "image/svg+xml");
let elm = doc.firstChild as SVGElement
return elm
}
}
- Puyo GameObject
ぷよも以下のように、自身の持つ色情報に応じたSVGタグ描画することで赤丸や青丸が見えるようになっている。Tsumoの中には2つのPuyoが、Fieldの中にはたくさんのPuyoがある。
export class Puyo extends GameObject {
public static colorCode: { [gamekey in PuyoColor] : string } = {
'None': '#112', 'Ojama': '#eee', 'Red': '#d33', 'Blue': '#33d', 'Green': '#3d3', 'Yellow': '#dd3'
}
public state: { movex: number, movey: number, color: PuyoColor }
private color: PuyoColor
constructor(x: number, y: number, color: PuyoColor, DOMID: DOMID) {
super(x, y)
this.color = color
this.DOMID = DOMID
this.state = { movex: 0, movey: 0, color: color }
}
protected toSVGElement (): SVGElement | null {
let xml = `
<g id="${this.DOMID}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${this.x + this.state.movex}" cy="${this.y + this.state.movey}" r="9.0" stroke="${Puyo.colorCode[this.color]}" stroke-width="0.1" fill="${Puyo.colorCode[this.state.color]}"></circle>
</g>`
let parser = new DOMParser();
let doc = parser.parseFromString(xml, "image/svg+xml");
let elm = doc.firstChild as SVGElement
return elm
}
}
ここまでトータル工数25時間。
(連鎖時のアニメーション描画どうしようかなあという不安を残しつつ、)次はWebSocket通信とサーバーサイド実装に着手する。