LoginSignup
3
2

More than 3 years have passed since last update.

オンライン対戦ぷよぷよをTypeScript + WebSocket + Goで作る - 3.画面設計

Last updated at Posted at 2019-11-24

前記事 2.詳細設計

前回までで大雑把に設計が完了した。
実装を進めたいがどこから入るか。クライアント側UI作成が一番心理的に不安が強い。そこから着手する。

今回はここまで進捗した。
「Lobby」から「Room」に入室し、2つの「Puyo(ぷよ)」から構成される「Tsumo(ツモ)」を操作し「Field」にPuyoを置いていくことができる所まで。

以下順番にやったことを解説する。

やったこと1.SVGタグを使った静的なUI実装

画面デザインとSVGコーディング。

Lobby画面

対戦ルームが8個ある仕様にした。
長方形rectとtextのセットを8Room分並べた。
スクリーンショット 2019-11-21 1.46.09.png

対戦Room画面

1P,2Pそれぞれのフィールド・Nextツモ・おじゃまぷよ予告のレイアウト配置決めながらSVGタグを書いた。
スクリーンショット 2019-11-21 1.47.49.png

やったこと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を表示する

図示すると↓
スクリーンショット 2019-11-24 11.44.57.png

また、キーボードからの入力受け付けについては上記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抽象クラスの一部

game_object.ts
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クラスの一部

main.ts

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実装

tsumo.ts
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の一部

field.ts
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がある。

puyo.ts
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通信とサーバーサイド実装に着手する。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2