NodeCGで配信を華やかに

[Splatoon2](https://www.nintendo.co.jp/switch/aab6a/index.html)の大会で運営として[OBS](https://obsproject.com/ja)を使って運営放送(配信)をする際に放送をかっこよくするために何か良いものはないかと探しているうちに[NodeCG](https://nodecg.com/)というものを見つけました。

# NodeCGで何ができる？
**OBS組み込みブラウザ(ブラウザソース)に用意したHTMLを表示させて、それを外部から操作することができます**
文字じゃよくわからないと思いますが使ってみるとおおすげぇってなりますたぶん

自分の場合は以前ブラウザに表示させてそれをキャプチャして表示させていたんですが、書き換えている間は違うシーンに変えなきゃいけないし、誤ってウィンドウ閉じてしまったり余計なものが映り込んだりしてしまう可能性があるので、~~その方法もまた別に良いやり方があるのかもしれないが~~書き換えが必要なものをブラウザソースで表示させることができるのはとても便利でした。

# 導入方法
については[cma2819氏](https://qiita.com/cma2819/items/e775bd8aa2a2fa911d4c)や[Hoishin氏](https://qiita.com/Hoishin/items/36dcea6818b0aa9bf1cd)が詳しく説明してくださっているのでそちらを参考にしてみてください。

# 実際につかってみる
NodeCGのbundlesフォルダの中にフォルダを作ります。

```
nodecg
└─bundles
    └─example
```

以後ここで作ったフォルダ名をexampleとして進めていきます。
その中に

```json:package.json
{
  "name": "example",
  "version": "1.0.0",
  "nodecg": {
    "compatibleRange": "^1.0.0"
  }
}
```

を置きます(nameはフォルダ名と同じにしておいてください)。
NodeCGを動かす際に必要となります。
加えて、exampleフォルダの中にdashboardとgraphicsというフォルダを作っておいてください。

今こんな感じ
![現在の構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/753f0854-c400-598d-d58f-f0cf57f25713.png)

## コメント表示
まずはdashboardフォルダの中に

```html:test.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>
  <input id="comment" type="text">
  <button id="submitButton">反映</button>
</body>
<script>
  const comment = document.getElementById("comment");
  const submitButton = document.getElementById("submitButton");

  const testRep = nodecg.Replicant("test");

  submitButton.addEventListener("click", () => {
    testRep.value = comment.value;
  });
</script>

</html>
```
次にgraphicsフォルダの中に

```html:index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>
  <div id="simple" style="font-size: 150px; color: white;"></div>
</body>

<script>
  const simple = document.getElementById("simple");

  const testRep = nodecg.Replicant("test", { defaultValue: "" });

  testRep.on("change", (newValue) => {
    simple.innerText = newValue;
  });
</script>

</html>
```

を作成します。

できたら先程exampleフォルダに置いたpackage.jsonに書き加えます。

```json:package.json
{
  "name": "example",
  "version": "1.0.0",
  "nodecg": {
    "compatibleRange": "^1.0.0",
    "dashboardPanels": [
      {
        "width": 2,
        "name": "test",
        "title": "コメント",
        "file": "test.html"
      }
    ],
    "graphics": [
      {
        "file": "index.html",
        "width": 800,
        "height": 600
      }
    ]
  }
}

```
これでひとまず完成です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/be02b14c-0905-100f-03b9-677f736c9a30.png)
動かしてみましょう。

ターミナルなどを開いてNodeCGのディレクトリに移動して実行します。

```
cd ここにnodecgディレクトリのアドレス
npm start
```

しばらくすると

```
info: [nodecg/lib/server] Starting NodeCG [NodeCGのバージョン] (Running on Node.js v[Node.jsのバージョン])
info: [nodecg/lib/server] NodeCG running on http://localhost:9090
```

みたいに表示されるので表示されたURL(上の場合は`http://localhost:9090`)をブラウザで開きます。
すると
![dashboard](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/a6ccc144-4790-0ac3-97b8-16917bc580ce.png)
何かかっこいいのが表示されました。これがダッシュボードで、ここで操作します。
このままだと表示するものがないので右上のGRAPHICSと書いてある目のマークをクリックしてください。
すると
![graphics](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/2806227e-7c25-98b3-8e3d-bb800d89b765.png)
こんな感じの画面が出てくるのでCOPY URLをクリックし、OBSを起動してコピーされたURLのブラウザソースを追加してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/338ad00a-d826-cf94-c5cd-df83ae7e1a3c.png)
追加した状態だと値が入っていないので何も表示されていません。
ひとまず先程開いたダッシュボードのWORKSPACEをクリックして操作画面に戻ります。
コメントと書いてある所に適当な文字を入力して反映ボタンをクリックしてみてください。
![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/0427cf81-df46-6e19-88df-44f7a11a96d8.gif)
**変わった！！！！！**
と、こんな感じで動かせます。

もっといろいろやってみましょう。
## 試合経過表示
※ここから先コードが長いので一部折りたたんであります。
こんなのも作れるんだくらいにみてもらえれば幸いです。

まずはdashboardフォルダの中に

<details><summary>panel.htmlを作りました。</summary><div>

```html:panel.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>
  <div class="teams">
    <div class="team">
      <label>チームA</label>
      <input class="name" type="text" placeholder="チーム名">
      <textarea class="member" placeholder="メンバー"></textarea>
    </div>
    <div class="team">
      <label>チームB</label>
      <input class="name" type="text" placeholder="チーム名">
      <textarea class="member" placeholder="メンバー"></textarea>
    </div>
  </div>
  <label>試合結果</label>
  <div class="games">
    <div class="game">
      <select class="winner"></select>
      <select class="rule"></select>
      <select class="stage"></select>
    </div>
    <div class="game">
      <select class="winner"></select>
      <select class="rule"></select>
      <select class="stage"></select>
    </div>
    <div class="game">
      <select class="winner"></select>
      <select class="rule"></select>
      <select class="stage"></select>
    </div>
    <div class="game">
      <select class="winner"></select>
      <select class="rule"></select>
      <select class="stage"></select>
    </div>
    <div class="game">
      <select class="winner"></select>
      <select class="rule"></select>
      <select class="stage"></select>
    </div>
  </div>
  <button id="submitButton">反映</button>
</body>

<script>
  const rules = [
    "ナワバリ", "ガチエリア", "ガチヤグラ", "ガチホコ", "ガチアサリ"
  ];
  const stages = [
    "バッテラストリート",
    "フジツボスポーツクラブ",
    "ガンガゼ野外音楽堂",
    "チョウザメ造船",
    "海女美術大学",
    "コンブトラック",
    "マンタマリア号",
    "ホッケふ頭",
    "タチウオパーキング",
    "エンガワ河川敷",
    "モズク農園",
    "Bバスパーク",
    "デボン海洋博物館",
    "ザトウマーケット",
    "ハコフグ倉庫",
    "アロワナモール",
    "モンガラキャンプ場",
    "ショッツル鉱山",
    "アジフライスタジアム",
    "ホテルニューオートロ",
    "スメーシーワールド",
    "アンチョビットゲームズ",
    "ムツゴ楼"
  ];

  const random = "？ ？ ？";
  const blank = "-";

  const nameInputs = document.getElementsByClassName("name");
  const memberInputs = document.getElementsByClassName("member");
  const winnerInputs = document.getElementsByClassName("winner");
  const ruleInputs = document.getElementsByClassName("rule");
  const stageInputs = document.getElementsByClassName("stage");
  
  const teamSet = () => {
    Array.from(nameInputs).forEach((name, index) => {
      Array.from(winnerInputs).forEach(input => {
        input.options[index+1] = new Option(name.value);
      });
    });
  };

  Array.from(nameInputs).forEach(input => {
    input.addEventListener("change", teamSet);
  });
  Array.from(winnerInputs).forEach(input => {
    input.options[0] = new Option(blank);
  });
  Array.from(ruleInputs).forEach(input => {
    const ruleOptions = rules.map(rule => new Option(rule));
    [new Option(random), ...ruleOptions].forEach((option, index) => {
      input.options[index] = option;
    });
  });
  Array.from(stageInputs).forEach(input => {
    const stageOptions = stages.map(stage => new Option(stage));
    [new Option(random), ...stageOptions].forEach((option, index) => {
      input.options[index] = option;
    });
  });

  const submitButton = document.getElementById("submitButton");

  const resultRep = nodecg.Replicant("result");

  submitButton.addEventListener("click", () => {
    resultRep.value = {
      teams: Array.from(nameInputs).map((name, index) => {
        return {
          name: name.value,
          member: memberInputs[index].value
        };
      }),
      games: Array.from(winnerInputs).map((winner, index) => {
        return {
          winner: winner.value,
          rule: ruleInputs[index].value,
          stage: stageInputs[index].value
        };
      })
    };
  });
</script>

<style>
  * {
    font-size: 15px;
    font-family: 'FOT-くろかね Std', sans-serif;
  }

  .teams, .games {
    display: flex;
    flex-flow: column;
    gap: 10px;
    margin-bottom: 30px;
  }

  .team {
    display: flex;
    flex-flow: column;
  }

  .winner {
    display: block;
  }

  .rule, .stage {
    display: inline-block;
  }
</style>

</html>
```
</div></details>

こんな感じでオブジェクトにすることで複数の値を送ることもできちゃいます。

次にgraphicsフォルダの中に

<details><summary>bo3.htmlを作りました。</summary><div>

```html:bo3.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>
  <div class="container">
    <div class="teams">
      <div class="team">
        <div class="counter">0</div>
        <div class="name">チーム名</div>
        <div class="member">メンバー</div>
      </div>
      <div class="vs">VS</div>
      <div class="team">
        <div class="counter">0</div>
        <div class="name">チーム名</div>
        <div class="member">メンバー</div>
      </div>
    </div>
    <div class="games">
      <div class="game">
        <div class="winner">-</div>
        <div class="display">
          <img class="image" src="./image/stage/random.png">
          <div class="number">1</div>
        </div>
        <div class="rule">？ ？ ？</div>
        <div class="stage">？ ？ ？</div>
      </div>
      <div class="game">
        <div class="winner">-</div>
        <div class="display">
          <img class="image" src="./image/stage/random.png">
          <div class="number">2</div>
        </div>
        <div class="rule">？ ？ ？</div>
        <div class="stage">？ ？ ？</div>
      </div>
      <div class="game">
        <div class="winner">-</div>
        <div class="display">
          <img class="image" src="./image/stage/random.png">
          <div class="number">3</div>
        </div>
        <div class="rule">？ ？ ？</div>
        <div class="stage">？ ？ ？</div>
      </div>
    </div>
  </div>
</body>

<script>
  const bo = 3;
  const random = "？ ？ ？";

  const counterSpaces = document.getElementsByClassName("counter");
  const nameSpaces = document.getElementsByClassName("name");
  const memberSpaces = document.getElementsByClassName("member");
  const winnerSpaces = document.getElementsByClassName("winner");
  const ruleSpaces = document.getElementsByClassName("rule");
  const stageSpaces = document.getElementsByClassName("stage");
  const imageSpaces = document.getElementsByClassName("image");

  const resultRep = nodecg.Replicant("result", { persistent: false });

  resultRep.on("change", newValue => {
    if (!newValue) return;
    newValue.teams.forEach((team, index) => {
      nameSpaces[index].innerHTML = team.name;
      memberSpaces[index].innerText = team.member;
      const win = newValue.games.filter((game) => game.winner === team.name && index < bo);
      counterSpaces[index].innerHTML = win.length;
    });
    newValue.games.forEach((game, index) => {
      if (index < bo) {
        winnerSpaces[index].innerHTML = game.winner;
        ruleSpaces[index].innerHTML = game.rule;
        stageSpaces[index].innerHTML = game.stage;
        imageSpaces[index].src = `./image/stage/${game.stage === random ? "random" : game.stage}.png`;
      }
    });
  });
</script>

<style>
  body {
    margin: 0;
  }

  .container {
    background-color: #2c2f33;
    color: #fff;
    font-size: 10vh;
    position: relative;
    width: 100vw;
    height: 100vh;
    overflow: hidden;
    font-family: 'FOT-くろかね Std', sans-serif;
  }

  .teams {
    position: absolute;
    top: 6vh;
    width: 80vw;
    margin: 0 10vw;
    display: flex;
    justify-content: space-between;
  }

  .vs {
    margin: auto 0;
  }

  .team {
    width: 31%;
    text-align: center;
  }

  .counter {
    font-size: 120%;
    line-height: 1.5;
  }

  .name {
    font-size: 50%;
  }

  .member {
    font-size: 35%;
    line-height: 1.5;
  }

  .games {
    position: absolute;
    bottom: 10vh;
    width: 80vw;
    margin: 0 10vw;
    display: flex;
    justify-content: space-between;
    font-size: 35%;
  }

  .game {
    width: 31%;
    overflow: hidden;
  }

  .display {
    position: relative;
    width: 100%;
    margin-bottom: -5%;
  }

  .image {
    width: 100%;
    object-fit: contain;
  }

  .number {
    position: absolute;
    top: 0;
    left: 0;
    width: 12%;
    background: rgba(0, 0, 0, 0.6);
    color: white;
    text-align: center;
    font-size: 70%;
  }

  .rule, .stage {
    line-height: 1.5;
  }
  
</style>

</html>
```
</div></details>

こんな感じで作っていきます。

今回は`persistent: false`にすることで前回分のデータが残らないようにして、初期値をHTML上に既に入れています。
画像とかはまあ適当に用意してください。

先程と同様にpackage.jsonに書き加えていきます。
ここでは書きませんがbo3.htmlと同じようにbo5.htmlも作りました。

```json:package.json
{
  "name": "example",
  "version": "1.0.0",
  "nodecg": {
    "compatibleRange": "^1.0.0",
    "dashboardPanels": [
      {
        "width": "3",
        "name": "panel",
        "title": "試合経過",
        "file": "panel.html"
      },
      {
        "width": 2,
        "name": "test",
        "title": "コメント",
        "file": "test.html"
      }
    ],
    "graphics": [
      {
        "file": "bo3.html",
        "width": 1920,
        "height": 1080
      },
      {
        "file": "bo5.html",
        "width": 1920,
        "height": 1080
      },
      {
        "file": "index.html",
        "width": 800,
        "height": 600
      }
    ]
  }
}

```

最終的なファイル構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/b7c0d350-f16f-b75b-e8a5-f907502e4e27.png)

これでもう一度実行します。
すると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/16b58ba2-63bf-fd5d-65c2-d848c7817872.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/661d97f5-4fae-c046-7569-141cb2467d35.png)
項目が増えました。

URLをコピーしてOBSにブラウザソースとして追加するんですが、幅と高さの数値を自分が作ったものと同じ値に変更しておいてください。
また、bodyを背景として使う場合はカスタムCSSの部分を空白にしておいてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/936cf7f6-6146-6a57-4a0e-bb3ba7ba7f6c.png)

入力欄に入力して反映ボタンをクリックすれば画面内でも変わります。
![test2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519040/2ce6dbab-c758-8224-1f70-dd719d9ae8f1.gif)
同じように作ったbo5.htmlも同じ操作パネルで同時に変更できます。

# 感想とか
最近は[React](https://ja.reactjs.org/)や[Solid.js](https://www.solidjs.com/)なんかと合わせて使ってます。
表示の切り替えにアニメーションを付けたりできると切り替え自体も演出にできるかも