脚本をjsonにパースしてみる
ごぶさたしております。ちょびえです。
今日は脚本テキストをパースしてjsonの構文データを作ってみようかと思います。
背景
物語を進めるタイプのゲームを作る上で、なんかしらの脚本テキストからゲーム向けの演出設定ファイルなどに変換するタスクというのが大抵の場合あったりします。
小さめのゲームであれば何でも好きやればよいのですが、ある程度の規模があるゲームの場合、脚本テキストデータだけでも全部読むだけで数日かかってしまう分量が最終的に出来上がるので何かしらの前処理系統とメンテ更新しやすいデータというものが必要になってきます。
後処理の変換の準備をやるにはどうアプローチすればいいか、というところで今回は問題をシンプルにしてプレーンな脚本テキストから構文木を作ってexcelファイルへの書き出しまでやってみます。
脚本テキストのデータはどれくらいになるのか
全部読むだけで数日って具体的にどんだけの分量よ、という感じなので具体的な数字を出してみると、例えば1章8話あるとして、10章ある場合。
10章 * 8話 * 体感的な係数(盛り上がりで増えたりするので)で1.5かけて約100ファイル。
これに加え、サブシナリオや細かいショットのお話などを含めるとだいたい3~500ファイル程度に増えます。
スマートフォン向けゲームの場合、文字数はおおよそ1ファイルあたり1000~2000文字ぐらいが多いかと思います。
感覚的な平均で1ファイル2000文字とした場合、300ファイルあると60万文字になります。
60万という数字は文庫本に概算すると約4~5冊分の情報量があります。ゲーム内容によって多少の幅はありますが、概ね文庫本数冊分くらいの情報量を扱うと思っておくと大きなズレはないと思います。
その大量の文章からゲームに組み込む最終フォーマットに変えていく後工程が必要となるのですが、これを手作業でやるとなるとコピペの往復でだいぶたるいです。
勿論、演出実装はその性質上手作業なんですが、その下準備くらいは自動化したいもんです。
脚本~組み込みまでのワークフロー
プログラムに入る前に、ゲームを作る上での脚本から組み込みまでのワークフローをざっと書き下してみます。
物語、世界観、キャラクター設定などは済んでいるという状況では概ねこんな感じです。
・プランナー/脚本家: プロット
・脚本家: 初稿
・プランナー/脚本家: 調整、表現チェック、演打ち
・脚本家: 修正稿
・プランナー/脚本家: 調整、表現チェック、演打ち
・脚本家: 改訂稿
・プランナー: 演出仮組み
・プログラマー: (演出に足りない機能づくり)
(↓からは修正するとやり直しが多くなります)
・脚本家: 最終稿
・プランナー: 演出本組
・脚本家/プランナー: 調整
・QA: フィードバック
・脚本家/プランナー: 最終調整
だいぶ端折ったり、タイムライン的には多少ずれがありますが1話つくるにもそれなりの工程があります。
脚本の構文
今回の記事では脚本データをプログラムからパースしよう、ということなのですが脚本テキストはどういった規則なのでしょうか。
調べた所、ローカルルール等はありますが、日本の場合大まかに下記のような形式のテキストなようです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
▽ 柱 (はしら。○とか★とか、本質的には物語の場面が変わったりするときに使います。 ローカルルールによって記号で意味合いが変わります) ト書き(全角空白2段下げで画面の説明を書いたりします) 曰く、~と片手を上げ。って演出指示等でかかれていた慣習からト書きと呼ばれるんすね。 キャラ名 「セリフ」 キャラ名 「セリフ」 キャラ名 「セリフ 次の行にまたぐ場合はセリフの頭を合わせます」 キャラ名A・キャラ名B 「セリフ」 キャラ名 「セリフ」 # コメント。脚本では使いませんがプログラマ的には欲しくなるやつです。 # 脚本テキストの場合全角が好まれます(変換変えるの面倒ですしね) |
内容的には状況説明や、誰が何を喋っているのかというのが分かる(ほぼ)定形のフォーマットになっています。
形式もわかりましたし、脚本テキストをプログラムからパースしてみます。
コードを書いてみる
ざっくりと書いていきます、動けばOKの精神でlexer側にある程度情報埋め込みつつ、parser側は楽をするという感じで作っていきます。
実行がしやすいようにJavaScriptで書いていますのでお手元のnode.js等で試してみてください。
尚、文字コードはUTF8固定と仮定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 |
"use strict"; var TokenType = { // デバッグ時に読みづらいので文字列で置いとく HASHIRA: "HASHIRA", TOGAKI_BEGIN: "TOGAKI_BEGIN", TOGAKI_END: "TOGAKI_END", NAME: "NAME", TEXT: "TEXT", LEFT_BRACKET: "LEFT_BRACKET", RIGHT_BRACKET: "RIGHT_BRACKET", SPACE: "SPACE", LINEFEED: "LINEFEED", COMMENT: "COMMENT", EOF: "EOF", } var LexerState = { DEFAULT: 0, TOGAKI: 1, QUOTE: 2, } class Token { constructor(inType, inValue, inOffset, inLength) { this.type = inType; this.value = inValue; this.offset = inOffset; this.length = inLength; } toString() { return `[${this.type} ${this.value}]\n`; } } class Lexer { peek() { if (this.current+1 >= this.tokens.length) { return new Token(TokenType.EOF); } return this.tokens[this.current+1]; } skip_space() { let i = this.current; for (; i < this.tokens.length; i++) { if (this.tokens[i].type == TokenType.SPACE || this.tokens[i].type == TokenType.LINEFEED) { this.current++; continue; } else { break; } } } assume(type) { if (this.current >= this.tokens.length) { return new Token(TokenType.EOF); } if (this.tokens[this.current].type == type) { return true; } return false; } cur() { if (this.current >= this.tokens.length) { return new Token(TokenType.EOF); } return this.tokens[this.current]; } next() { this.current++; if (this.current+1 >= this.tokens.length) { return new Token(TokenType.EOF); } return this.tokens[this.current]; } consume() { this.current++; if (this.current+1 >= this.tokens.length) { return; } } constructor(text) { this.tokens = []; this.current = 0; let length = text.length; let offset = 0; let state = LexerState.DEFAULT; let is_line_head = true; for (var i = 0; i < length;) { let v = this.is_linefeed(text, i); if (v > 0) { this.push_token(new Token(TokenType.LINEFEED, text.substr(i, v), i, v)); i += v; is_line_head = true; continue; } let character = text[i]; if (state == LexerState.DEFAULT) { if (is_line_head && this.is_comment(character)) { for (var j = i + 1; j < length; j++) { let x = this.is_linefeed(text, j); if (x > 0) { this.push_token(new Token(TokenType.COMMENT, text.substr(i+1, j - x - 1).trim(), i + 1, j - x - 1)); i = j - x; break; } } } else if (this.is_space(character)) { if (is_line_head && this.is_space(text[i+1])) { // ト書きである state = LexerState.TOGAKI; i += 2; this.push_token(new Token(TokenType.TOGAKI_BEGIN, null, i-2, 2)); continue; } else { for (var j = i; j < length; j++) { let second = text[j]; if (!this.is_space(second)) { i = j; break; } } } let value = text.substr(i, j - i); this.push_token(new Token(TokenType.SPACE, value, i, j - i)); continue; } else if (is_line_head && this.is_hashira(character)) { this.push_token(new Token(TokenType.HASHIRA, character, i, 1)); for (var j = i + 1; j < length; j++) { let x = this.is_linefeed(text, j); if (x > 0) { this.push_token(new Token(TokenType.TEXT, text.substr(i+1, j - i - x).trim(), i + 1, j - i - x)); i = j; break; } } continue; } else if (character == "「") { this.push_token(new Token(TokenType.LEFT_BRACKET, "「", i, 1)); i++; state = LexerState.QUOTE; continue; } else if (is_line_head) { for (var j = i; j < length; j++) { if (this.is_space(text[j])) { this.push_token(new Token(TokenType.NAME, text.substr(i, j - i), i , j - i)); i = j - 1; break; } } is_line_head = false; continue; } } else if (state == LexerState.TOGAKI) { for (var j= i; j < length; j++) { let x = this.is_linefeed(text, j); if (x > 0) { this.push_token(new Token(TokenType.TEXT, text.substr(i, j - i), i, j - i)); i = j; break; } } if (this.is_space(text[i]) && this.is_space(text[i+1])) { continue; } this.push_token(new Token(TokenType.TOGAKI_END, null, i, 0)); state = LexerState.DEFAULT; continue; } else if (state == LexerState.QUOTE) { if (is_line_head && this.is_space(character)) { let j = i; while (this.is_space(text[j]) && j < length) { j++; } this.push_token(new Token(TokenType.SPACE, text.substr(i, j - i), i , j -i)); i = j; is_line_head = false; continue; } else { for (var j= i; j < length; j++) { if (text[j] == "」") { this.push_token(new Token(TokenType.TEXT, text.substr(i, j - i), i, j - i)); this.push_token(new Token(TokenType.RIGHT_BRACKET, "」", j, 1)); i = j; state = LexerState.DEFAULT; break; } else { let x = this.is_linefeed(text, j); if (x > 0) { this.push_token(new Token(TokenType.TEXT, text.substr(i, j - i), i, j - i)); this.push_token(new Token(TokenType.LINEFEED, text.substr(j, x), j, x)); is_line_head = true; i = j + x; break; } } } continue; } } i++; is_line_head = false; } } dump() { for (var i= 0; i < this.tokens.length; i++) { console.log(this.tokens[i]); } } push_token(token) { this.tokens.push(token); } is_linefeed(text, offset) { if (text[offset] == "\r") { if (text[offset+1] == "\n") { return 2; } return 1; } else if (text[offset] == "\n") { return 1; } return -1; } is_hashira(value) { if (value == "▽" || value == "○" || value == "★" || value == "△") { return true; } return false; } is_comment(value) { // 全角#もしくは半角# if (value == "#" || value == "#") { return true; } return false; } is_space(value) { // 全角空白もしくは半角空白 if (value == " " || value == " ") { return true; } return false; } } class Parser { constructor(lex) { this.lex = lex; } internal_parse(result, level) { let end = false; while(true) { let next = this.lex.cur(); if (end) { break; } switch (next.type) { case TokenType.LINEFEED: case TokenType.SPACE: this.lex.consume(); break; case TokenType.NAME: let name = next; this.lex.consume(); this.lex.skip_space(); if (this.lex.assume(TokenType.LEFT_BRACKET)) { this.lex.consume(); let tmp = []; while (!this.lex.assume(TokenType.RIGHT_BRACKET)) { tmp.push(this.lex.cur()); this.lex.consume(); } result.push({type: "QUOTE", name: name.value, text: tmp.filter(x => x.type == TokenType.TEXT).map(x => x.value), length: tmp.filter(x => x.type == TokenType.TEXT).reduce((x, y) => x + y.value.length, 0) }); } else { throw new Error(this.lex.tokens[this.lex.current]); } break; case TokenType.HASHIRA: if (next.value == "★" && level > 0) { return; } if (next.value == "△" && level > 0) { return; } let p = {type: "HASHIRA", value: next.value, text: this.lex.next().value, children: []}; result.push(p); this.lex.consume(); this.internal_parse(p.children, level+1); break; case TokenType.COMMENT: this.lex.consume(); break; case TokenType.TOGAKI_BEGIN: { this.lex.consume(); let tmp = []; while (true) { this.lex.skip_space(); if (!this.lex.assume(TokenType.TOGAKI_END)) { tmp.push(this.lex.cur()); this.lex.consume(); } this.lex.consume(); this.lex.skip_space(); if (this.lex.assume(TokenType.TOGAKI_BEGIN)) { this.lex.consume(); continue; } result.push({type: "TOGAKI", text: tmp.map(x => x.value)}); break; } } break; case TokenType.TOGAKI_END: case TokenType.RIGHT_BRACKET: case TokenType.LEFT_BRACKET: this.lex.consume(); break; case TokenType.EOF: end = true; break; default: this.lex.consume(); break; } } } parse() { let result = []; this.internal_parse(result, 0); return result; } } ////////////////////////////////////////////////////////////////////////////////////////////////////// let data = ` ★ セクション1 # 行こめんと ▽ お昼前のオフィス ちょびえ、イスにうなだれながら ちょびえ 「あー。おなかすいた」 ちゃみ 「そうだね。わたしもーおなかすいちゃった」 ちょびえ 「久しぶりに焼き肉でもいかね?」 ちゃみ。窓を見ながらから考え込む ちゃみ 「うーん。今日雨だし、傘ささないでいけるところにしない?」 ちょびえ 「確かにそっちのほうが楽だわ んじゃハンバーガーでもいこうよ」 ちゃみ 「そうね。最近いってなかったし行きましょ。エレベーターホールで待ち合わせでいい?」 ちょびえ 「りょうかいー」 ################################################################## ★ セクション2 ▽ ハンバーガーショップにて 広い店内に、元気の良い店員達の声が響く 店員達 「イラッッシャーイ」 ちょびえ 「あいかわらず元気いっぱいな感じだ」 ちゃみ 「そうねー」 店員1 「ご注文はなんになさいますか?」 ちょびえ 「んじゃ。コーラとチーズハンバーガーで」 ちゃみ 「私はウーロン茶とアボカドハンバーガーお願いします」 店員1 「ありがとうございましたー」 △ おしまい `; let lex = new Lexer(data); //console.log(lex.tokens); let parser = new Parser(lex); let result = parser.parse(); console.log(require('util').inspect(result, false, null)); console.log("#stats:") console.log(" actors: " + getUniqueNames(result)); console.log(" length: " + getTotalLength(result)); console.log(" estimate_length: " + (getTotalLength(result) * 0.1) + " seconds"); console.log(""); console.log("#quotes:") let tmp = getCharacterQuoteLength(result); for (var name in tmp) { console.log(`${name}: セリフ数:${tmp[name].count} トータル:${tmp[name].total}`); } function getUniqueNames(result) { let hash = new Set(); for (var i = 0; i < result.length; i++) { let tmp = result[i]; for (var j = 0; j < tmp.children.length; j++) { let tmp2 = tmp.children[j]; for (var k = 0; k < tmp2.children.length; k++) { if (tmp2.children[k].type == "QUOTE") { hash.add(tmp2.children[k].name); } } } } return Array.from(hash).sort(); } //キャラごとのセリフ数(統計だすと細かいミスの気づきとか流れを違った面でみれるのでオススメ) function getCharacterQuoteLength(result) { let hash = {}; for (var i = 0; i < result.length; i++) { let tmp = result[i]; for (var j = 0; j < tmp.children.length; j++) { let tmp2 = tmp.children[j]; for (var k = 0; k < tmp2.children.length; k++) { if (tmp2.children[k].type == "QUOTE") { if (!(tmp2.children[k].name in hash)) { hash[tmp2.children[k].name] = { total: 0, count: 0 }; } hash[tmp2.children[k].name].total += tmp2.children[k].length; hash[tmp2.children[k].name].count++; } } } } return hash; } function getTotalLength(result) { let sum = 0; for (var i = 0; i < result.length; i++) { let tmp = result[i]; for (var j = 0; j < tmp.children.length; j++) { let tmp2 = tmp.children[j]; for (var k = 0; k < tmp2.children.length; k++) { if (tmp2.children[k].type == "QUOTE") { sum += tmp2.children[k].length; } } } } return sum; } |
と、こんな所でしょうか。あくまで前処理系のプログラムで実際にゲームに組み込まれるわけではないので動けばOKという方針で書いて大丈夫かと。
今回の記事では脚本フォーマットの柱の一部記号は特殊として扱うことにしました
・★はファイル分割用の目印とします
・△は終了の目印とします
実行
それでは実行してみます。普段私はnodeのv12使っているのでもしかしたらお手持ちの環境で動かないかもしれませんが・・・
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
[ { type: 'HASHIRA', value: '★', text: 'セクション1', children: [ { type: 'HASHIRA', value: '▽', text: 'お昼前のオフィス', children: [ { type: 'TOGAKI', text: [ 'ちょびえ、イスにうなだれながら' ] }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'あー。おなかすいた' ], length: 9 }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'そうだね。わたしもーおなかすいちゃった' ], length: 19 }, { type: 'QUOTE', name: 'ちょびえ', text: [ '久しぶりに焼き肉でもいかね?' ], length: 14 }, { type: 'TOGAKI', text: [ 'ちゃみ。窓を見ながらから考え込む' ] }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'うーん。今日雨だし、傘ささないでいけるところにしない?' ], length: 27 }, { type: 'QUOTE', name: 'ちょびえ', text: [ '確かにそっちのほうが楽だわ', 'んじゃハンバーガーでもいこうよ' ], length: 28 }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'そうね。最近いってなかったし行きましょ。エレベーターホールで待ち合わせでいい?' ], length: 39 }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'りょうかいー' ], length: 6 } ] } ] }, { type: 'HASHIRA', value: '★', text: 'セクション2', children: [ { type: 'HASHIRA', value: '▽', text: 'ハンバーガーショップにて', children: [ { type: 'TOGAKI', text: [ '広い店内に、元気の良い店員達の声が響く' ] }, { type: 'QUOTE', name: '店員達', text: [ 'イラッッシャーイ' ], length: 8 }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'あいかわらず元気いっぱいな感じだ' ], length: 16 }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'そうねー' ], length: 4 }, { type: 'QUOTE', name: '店員1', text: [ 'ご注文はなんになさいますか?' ], length: 14 }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'んじゃ。コーラとチーズハンバーガーで' ], length: 18 }, { type: 'QUOTE', name: 'ちゃみ', text: [ '私はウーロン茶とアボカドハンバーガーお願いします' ], length: 24 }, { type: 'QUOTE', name: '店員1', text: [ 'ありがとうございましたー' ], length: 12 } ] } ] }, { type: 'HASHIRA', value: '△', text: 'おしまい', children: [] } ] #stats: actors: ちゃみ,ちょびえ,店員達,店員1 length: 238 estimate_length: 23.8 seconds #quotes: ちょびえ: セリフ数:6 トータル:91 ちゃみ: セリフ数:5 トータル:113 店員達: セリフ数:1 トータル:8 店員1: セリフ数:2 トータル:26 |
と、こんな感じでパースしつつ簡単な統計情報がだせました。統計情報を出すと些細なミス等把握しやすいのでついでに作っとくと良い感じです。
Excelファイルへの展開
parseついでに適当にExcelファイルへ展開してみます。
1 2 |
npm install -g xlsx |
依存ライブラリのxlsxをインストールしつつ下記スクリプトを実行します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
"use strict"; let result = [ { type: 'HASHIRA', value: '★', text: 'セクション1', children: [ { type: 'HASHIRA', value: '▽', text: 'お昼前のオフィス', children: [ { type: 'TOGAKI', text: [ 'ちょびえ、イスにうなだれながら' ] }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'あー。おなかすいた' ], length: 9 }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'そうだね。わたしもーおなかすいちゃった' ], length: 19 }, { type: 'QUOTE', name: 'ちょびえ', text: [ '久しぶりに焼き肉でもいかね?' ], length: 14 }, { type: 'TOGAKI', text: [ 'ちゃみ。窓を見ながらから考え込む' ] }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'うーん。今日雨だし、傘ささないでいけるところにしない?' ], length: 27 }, { type: 'QUOTE', name: 'ちょびえ', text: [ '確かにそっちのほうが楽だわ', 'んじゃハンバーガーでもいこうよ' ], length: 28 }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'そうね。最近いってなかったし行きましょ。エレベーターホールで待ち合わせでいい?' ], length: 39 }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'りょうかいー' ], length: 6 } ] } ] }, { type: 'HASHIRA', value: '★', text: 'セクション2', children: [ { type: 'HASHIRA', value: '▽', text: 'ハンバーガーショップにて', children: [ { type: 'TOGAKI', text: [ '広い店内に、元気の良い店員達の声が響く' ] }, { type: 'QUOTE', name: '店員達', text: [ 'イラッッシャーイ' ], length: 8 }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'あいかわらず元気いっぱいな感じだ' ], length: 16 }, { type: 'QUOTE', name: 'ちゃみ', text: [ 'そうねー' ], length: 4 }, { type: 'QUOTE', name: '店員1', text: [ 'ご注文はなんになさいますか?' ], length: 14 }, { type: 'QUOTE', name: 'ちょびえ', text: [ 'んじゃ。コーラとチーズハンバーガーで' ], length: 18 }, { type: 'QUOTE', name: 'ちゃみ', text: [ '私はウーロン茶とアボカドハンバーガーお願いします' ], length: 24 }, { type: 'QUOTE', name: '店員1', text: [ 'ありがとうございましたー' ], length: 12 } ] } ] }, { type: 'HASHIRA', value: '△', text: 'おしまい', children: [] } ]; const XLSX = require("xlsx"); let wb = XLSX.utils.book_new(); let ws_data = []; ws_data.push(["name", "quote"]); for (var n in result) { for (var x in result[n].children) { let tmp = result[n].children[x].children; for (var j in tmp) { if (tmp[j].type == "QUOTE") { ws_data.push([tmp[j].name, tmp[j].text.join("\n")]); } else if (tmp[j].type == "TOGAKI") { ws_data.push([]); ws_data.push(["#", tmp[j].text.join("\n")]); ws_data.push([]); } } } } var ws = XLSX.utils.aoa_to_sheet(ws_data); XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); XLSX.writeFile(wb, "sample.xlsx"); |
実行して出来上がったsample.xlsxを開いてみるとこんな感じに仕上がりました。
構文情報がjsonになっていれば後工程は楽に処理できますね。今回はJavaScriptで書きましたがお好きな言語を使うで良いと思います。
若干今回のコードのはやっつけ感が漂いますが、実際ゲーム作って運用したりしていると、サブのツール的なものはできるだけ平易な設計で、そんなメンテしなくてもある程度動いてくれて、かつゲームの表現に合わせてビルドなしで手早くpatch当てられる方が日々の運用楽だな、と思います。
脚本の運用を見据えて
現場で脚本テキスト素のまま、というのは少ないのでテキストに特殊タグ埋め込み型だったり、スプレッドシート直だったりするんですが基本の脚本テキストを自力でパースできるだけのコード力があればあとはどうにでも料理できますね。
実際とあるゲームでも書式は違いますがテキストから演出テンプレデータへの展開などをプログラム経由でやっています。
開発が進んでいくと、脚本データ関連だけでもアレコレやりたいことが出てきます。
例えば
・ゲーム用の演出設定ファイルに出したい(今回のケース)
・脚本の段階で誤字脱字チェックを始めとした表現チェックをしたい/しなければならない
・統計データを出したい
・校正用の印刷したい
・ボイス収録用の台本作りたい
・diffとりたい
構文木データを作るプログラム単品ではあまり意味のないものですが、準備をしていくとこういった要望への応用は楽にできるかと思います。
あとがき(と免責)
・呼称やワークフローについてはチームや組織によって違うので平易な表現に落としてあります
・(好みの範囲だと思うので)テキストデータ/スプレッドシートなどはチームにあったものを使うのが良いと思います
・サンプルプログラムは説明用に書いたものなのでケース処理が甘かったりします。あくまでご自身の責任の範囲内でご利用ください。
それでは~