自作RPG向けハードコーティング考~ドラクエセリフの1文字づつ表示

勘違いしながらどハマりしながら作り上げる爺の『JavaScript奮闘記』。爺のくせにゲームのプログラミングに興味を持ってしまいました。

ポク太郎です。自作RPG製作中です。

ゲーム製作とは苦行である。

そう悟った爺。その根拠となるコトガラですが、“ハードコーティング”がめちゃめちゃ大変で、仕様が固まりません。

その言い訳的な話と、とりあえず動くようにした「ドラクエの一定時間ごとに1文字づつ進むセリフ表示」の例です。


ハードコーティングとは何ぞ

例えばゲームのコントローラ。連射機能を付けようと電気回路をいじると→連射付コントローラ普通のコントローラの2種類出来上がってしまいます。
⇒もし、スイッチで切り替えられるようにすれば、1種類ですべてまかなえます。

同じく、パソコンのマザーボード。多種多様な機能実現のため多種多様なバージョンを作ると→マザーボードが多種類出来上がってしまいます。
⇒もし、BIOSの設定値で動きを制御すれば、1種類ですべてまかなえます。

コントローラマザーボード作るにはそれぞれ向けの設計・製造設備・何やかやが必要ですが、“操作で切り替えられる”ようにしておくことで、何にでも対応できるように。

これが“ハードコーティング”。

同じ話で、ゲームのプログラム。王様だけでなく、竜王との会話も追加したい場合にプログラムをいじると→王様との会話ルーチン竜王との会話ルーチンの2種類が出来上がってしまいます。
⇒もし、データ部分(つまりは単なるテキストファイル編集)での“発言者”の設定枠に従うようにしておけば、1種類ですべてまかなえます。

このように、“より簡単に切り替える”ために、“変更に労力が掛かる方”をあらかじめ作りこんでおくことを“ハードコーティング”。

だから“ハード”って部分はハードウェアの意味でなく、作り替えに“厳しい”方て意味が正解なのかな。

「プリント基板作り替え・プログラムのソースいじくり」より、「スイッチで切り替え・BIOSで切り替え・データ(単なるテキストファイル編集)」で切り替える方が遥かに簡単。

そういった作り方をしておけば、「ゲームを作る」が「プログラム作る」と「ゲームのデータ作る」部分に完全に分離され、分業化もできるし何より混乱することが無くなります。

で、ジジイが「苦行だ」と言ってるのはココ。

ハードコーティングのために必要なのは取り決め

どのようなフォーマットのデータを渡せば自分のやりたいこと全部実現可能か、がなかなか定まらず、どっちが鶏でどっちが卵か分からなくポイント。

例はドラクエの1文字づつ進むセリフデータ

そんな中、とりあえず形になった「ドラクエの一定時間ごとに1文字づつ進んでいくセリフデータ」の記述フォーマット。

ここでのハードコーティングの対象は、王様竜王のデータ共通化でなく、改行入力待ちYes/No質問等。セリフデータの形式を取り決め、これらを1行のデータとして表すことを図ります。

下表にある文字列を枠内に貼り付けてスタートボタンをクリック。テンキー82で上下、Mキーがボタン。


ドラクエの一定時間ごとに1文字づつ進んでいくセリフデータ
例1 もょもと… おきなさい もょもと…★↓きょうは あなたの 16回目の誕生日。わたしはこれまで あなたを 立派な大人にするために 懸命に育ててきました。◆遂にきょうは 王様に呼ばれたのよ さぁ起きなさい。
例2 宿屋へようこそ。◆いっぱく pny} ですが お泊りですか?◆◇□それでは ごゆっくり どうぞ。□■また おこしくださいませ。 ご無理なさいませぬよう。■
例3 この通りじゃ たのむ!●おねがいじゃ もょもと わしの願いを 叶えてくれ!◆◇□さすがは もょもと じゃな。□■※■
仕様 ◆…改行。
★…一時停止しMキー待ち。
↓…画面クリア。
◇と□□■■…Yes/No窓表示し返答待ち。Yes選択後は□□間、No選択後は■■間。
●と□□■■と※…※でない方を選ばないと、永遠に●から繰り返し。

ドラクエの1文字づつ進むセリフJavaScript

1文字づつ進む台詞ソースコード

    // グローバル変数
    let isGameStarted = false;

    let gMsg;				//入力されるメッセ

    let txtNow = '';
    let txtfullNow = '';
    let chrid = 0;
    let chrON = false;				//表示する文字:true、非表示:false
    let waitInput = false;			//Sメッセ表示停止中で入力待ちtrue
    let markON = true;				//入力待ちの点滅カーソル表示:true、消:false

    let f_ynwin = false;			//YN窓表示中:true、閉じてる:false
    let ynyns = 0;

    let YCmsg = "";
    let NCmsg = "";
    
    // 選択肢表示前のメッセージを別途管理するための変数
    let preCmsg = ""; 
    // YN窓が表示された後か追跡するフラグ
    let isAfterYN = false;

let gFrame = 0;
const FPS = 60;
const gFmemo = new Array(2);
const lf='\n';

let ctrlph=0;		//Sメッセ窓表示中
let gMsgcomp=0;		//Sメッセ窓の表示がすべて完了した状態
let f_stepmsg=0;	//Sメッセ窓の表示中
const gMsgsp=40;	//Sメッセ間隔[ms] 遅い75	速い35
let msgsp=gMsgsp;	//Sメッセ間隔[ms]調整用

// 時間[ms]経過したらtrueを返す
function Timing(mode, t = 0) {
        if (mode === 0) {
            gFmemo[1] = gFrame;
            return false;
        } else {
            if (gFrame > gFmemo[1] + FPS * t / 1000) {
                return true;
            }
        }
}

function chkLMptn(dt){
//	無限パターン●がないかチェックする
//	この通りじゃ たのむ!●おねがいじゃ nm1} わしの願いを 叶えてくれ!◇□さすがは nm1} じゃな。□■※■cond}したいんじゃ。
var mode;var target;
	if((kw(dt,'●') && !kw(dt,'※')) || !kw(dt,'●') && kw(dt,'※')){
		return '【エラー】セリフのフォーマットが異常です。●※は対で使用されます。';}

	if(kw(dt,'●')){mode='mugen';}else{mode='normal';}

	if(mode=='mugen'){
			target=nthf(nthf(dt,'◇',1),'●',2);
			return replaceall(dt,'※',target+'◇')
	}else{
			return dt;
	}
}

function makeYNptn(dt,mode){
//	YNパターンとして作り直す
var tmp,tmp2,tmp3;
		if(kw(dt,'◎')){
			return nthf(dt,'◎',1)+nthf(dt,'◎',2)+nthf(dt,'◎',3);
		}else{
			if(mode=='□'){	tmp='■'+nthf(dt,'■',2)+'■';
					tmp2=replaceall(dt,tmp,'');	}
			else{		tmp='□'+nthf(dt,'□',2)+'□';
					tmp2=replaceall(dt,tmp,'');	}

					tmp3=replaceall(tmp2,mode,'');
					return tmp3;
		}
}

function EnableYN(){
//	YN窓決定された
                f_ynwin = false;//YN窓閉じる条件
		ctrlph=0;////////////////////

	//	※があるのがどっちか調べる
	if(!kw(YCmsg,'※') && !kw(NCmsg,'※')){
		if(ynyns==0){	showSmsg(YCmsg);
		} else {	showSmsg(NCmsg);
		}
	}else{
	const aftJmsg = nthf(gMsg,'●',2);
		if(kw(YCmsg,'※')){
				if(ynyns==0){	showSmsg('●'+aftJmsg);
				} else {	showSmsg(NCmsg);
				}
		}
		if(kw(NCmsg,'※')){
				if(ynyns==0){	showSmsg(YCmsg);
				} else {	showSmsg('●'+aftJmsg);
				}
		}
	}
}


function CHKpad( k ){
        if (waitInput && (k === 'm' || k === 'M')) {
            waitInput = false;
            Timing(0);
            markON = true;
        }
        if (f_ynwin) {
            if (k === '8') {
                ynyns = 0;
            } else if (k === '2') {
                ynyns = 1;
            } else if (k === 'm' || k === 'M') {
//                txtNow += lf;
		EnableYN();
            }
        }
}

function SetMsg( v1 ){
//	Sメッセ開始時の準備
	if(f_stepmsg==1){    return;    }else{	f_stepmsg=1;	}	///////

//	v1=chkLMptn(v1);
        const Ymsg = makeYNptn(v1,'□');
        const Nmsg = makeYNptn(v1,'■');

        // メッセージを分離
    if(kw(Ymsg,'◇')){
        preCmsg = nthf(Ymsg,'◇',1);
	//文章内にYN窓の◇が1つだけ→preCmsg終了時がYN窓出すタイミング
        YCmsg = nthf(Ymsg,'◇',2);
        NCmsg = nthf(Nmsg,'◇',2);
    } else {
        preCmsg = Ymsg;
        YCmsg = '';
        NCmsg = '';
    }

        // 画面をクリアしてメッセージ表示を開始
    isAfterYN = false; // 新しいメッセージでフラグをリセット
        txtNow = '';
f_stepmsg=1;	////////////////////////////
        showSmsg(preCmsg);
        if (!isGameStarted) {
            gameLoop();
            isGameStarted = true;
        }

}

    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    const startButton = document.getElementById('startButton');
    const MessageInput = document.getElementById('MessageInput');
    startButton.addEventListener('click', () => {
        gMsg = MessageInput.value;

	f_stepmsg=0;	////////////////////////////
	SetMsg( gMsg );
    });

    // メッセージ表示関連
    function showSmsg(msg) {
        txtfullNow = msg;
        chrid = 0;
        Timing(0);
        chrON = true;
        waitInput = false;
        markON = true;
    }

    function updateSmsg() {
        if (!chrON && !waitInput && !f_ynwin) {
            return;
        }
        if (waitInput) {
            if (Timing(1, 100)) {
                markON = !markON;
                Timing(0);
            }
            return;
        }
        if (f_ynwin) {
            return;
        }
        if (Timing(1, msgsp)) {
		//oto('ok1');	/////////////プツプツ音鳴らす
            if (chrid >= txtfullNow.length) {
                chrON = false;
                // Sメッセ内容すべて表示された
                gMsgcomp = 1;	//////////////////////
                ctrlph = 1;	//////////////////////
                // 最初のメッセージが完全に表示された場合にのみ選択肢を表示
                // preCmsg終了、かつ、NCmsgが空白でない
                if (txtfullNow === preCmsg && (YCmsg !== "" || NCmsg !== "")) {
                    f_ynwin = true;//これがYN窓開いている条件
			ctrlph=-1;////////////////////////////
                        isAfterYN = true; // YN窓が表示された後にフラグを立てる
                }
                return;
            }
            
            const lines = txtNow.split(lf);
            const lastLine = lines[lines.length - 1];
            
            const isAfterAutoNewline = lastLine.length >= 33;
            const nextChar = txtfullNow[chrid];
            
            // 全角空白の自動スキップ
            const isBeginningOfLine = (txtNow.length === 0 || txtNow.endsWith(lf));
            if ((isBeginningOfLine || isAfterAutoNewline) && nextChar === ' ') {
                chrid++;
                Timing(0);
                return;
            }

            switch (nextChar) {
                case '★':
                    waitInput = true;
                    chrid++;
                    txtNow += lf;
                    Timing(0);
                    return;
                case '◆':
                    txtNow += lf;
                    chrid++;
                    Timing(0);
                    return;
                case '↓':
                    txtNow = '';
                    chrid++;
                    Timing(0);
                    return;
                case '◇':
                    chrON = false;
                    f_ynwin = true;
                    chrid++; // ◇をスキップ
                    return;
                case '●':
                    if (isAfterYN) {
                    txtNow = ''; // YN窓の後なら画面クリア
                    }else{
                    txtNow += lf;
				}
                    chrid++;
                    Timing(0);
                    return;
                default:
                    if (isAfterAutoNewline) {
                        txtNow += lf;
                    }
                    txtNow += nextChar;
                    chrid++;
                    Timing(0);
                    return;
            }
        }
    }

    function drawSmsg(ctx) {
        if (chrON || waitInput || f_ynwin || txtNow !== '') {
            const boxX = 50;
            const boxY = 150; 
            const boxWidth = 500;
            const boxHeight = 200;
            ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
            ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
            ctx.strokeStyle = '#FFFFFF';
            ctx.lineWidth = 2;
            ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);

            ctx.fillStyle = '#FFFFFF';
            ctx.font = '14px sans-serif';
            const lines = txtNow.split(lf);
            let y = boxY + 30;
            for (const line of lines) {
                ctx.fillText(line, boxX + 20, y);
                y += 30;
            }

            if (waitInput && markON) {
	//ボタンを促す点滅表示
                const markX = boxX + boxWidth/2;
                const markY = boxY + boxHeight - 20;
                ctx.fillStyle = '#FFFFFF';
                ctx.font = '14px sans-serif';
                ctx.fillText('▽', markX, markY);
            }
            if (f_ynwin) {
	//YN窓表示
                const choiceBoxX = boxX + 300;
                const choiceBoxY = boxY - 100;
                const choiceWidth = 150;
                const choiceHeight = 80;

		//ynyns=0;	///////////////////////////
		//DrawYN;	///////////////////////////

                ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
                ctx.fillRect(choiceBoxX, choiceBoxY, choiceWidth, choiceHeight);
                ctx.strokeStyle = '#FFFFFF';
                ctx.strokeRect(choiceBoxX, choiceBoxY, choiceWidth, choiceHeight);

                ctx.fillStyle = '#FFFFFF';
                ctx.font = '14px sans-serif';
                ctx.fillText('はい', choiceBoxX + 20, choiceBoxY + 30);
                ctx.fillText('いいえ', choiceBoxX + 20, choiceBoxY + 60);

                const cursorY = (ynyns === 0) ? choiceBoxY + 30 : choiceBoxY + 60;
                ctx.fillText('→', choiceBoxX, cursorY);
            }
        }
    }

    // メインのゲームループ
    function gameLoop() {
        gFrame++;
        updateSmsg();
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        drawSmsg(ctx);
        requestAnimationFrame(gameLoop);
    }

    // イベントリスナー
    document.addEventListener('keydown', (event) => {
			CHKpad( event.key );
    });

中で使用する汎用の関数

function replaceall(dt,moji,moji2){
//指定の文字列に全置換
		dt = dt.split(moji);
		return dt.join(moji2);	}
//指定のフィールドデータの総数を返す
function nthc(dt,spr) {
		var tmp;
		var r=0;
	if (dt.indexOf(spr) == -1){
				r=0;
	} else {
				tmp=dt.split(spr);
				r=tmp.length;
	}
				return r;	}

//指定のフィールドデータを取り出す
function nthf(dt,spr,n) {
		var tmp;
		var rt='';
	if (dt.indexOf(spr) == -1){
				rt=dt;
	} else {
				tmp=dt.split(spr);
		if (n<1||n>tmp.length){
				rt='';
		} else {
				rt=tmp[n-1];
		}
	}
				return rt;	}

//文字列の中に指定の文字あるかどうか判断
function kw(str, keyword){
	if( str.indexOf(keyword) != -1 ){
		return true;
	} else {
		return false;
	}
}

ジジイの悟り「ゲーム製作とは苦行である」。ハードコーティングの仕様が固まらないってのも一大要因ですが、もう一つ。

通常何が通常なのかは分かりませんが、プログラムとは演算の対象が存在して、それに対して処理を考えるもの。例えば、測定器が吐き出すデータ列とか、こんなあんな項目を持つ伝票とか。

ゲームのプログラミングとは、その演算対象となるゲームのデータがまだ存在しない中で、自分の成すべきことを想定して作らないといけないもの。

でも、作るためには多少のサンプルデータ準備しながら、プログラム作りながら…。で、結局→「あっりゃ~!これじゃあ実現できんやん!」→全部作り直し。

つまり、ここでも「どっちが鶏でどっちが卵か分からない」状態で作業。それがゲームのプログラミング。厳しいわ。ドラクエという完璧なお手本が存在してもこのザマなんだ。

やっぱ、ゲーム系のソフトハウスって、その辺に相応なノウハウを持ってんでしょうな。それがまさにRPGツークルウディタってことなのかな。結局はそれ作る仕事やん!

それがゲームプログラミングの道を歩み始めた爺の感想です。

コメント

タイトルとURLをコピーしました