ChatGPT4に作ってもらったRPGゲームの素その3。~カメラ~

前回

表示すべきデータと
表示する方法を分けて考えます。

GraphicsAPIはプログラマがディスプレイに対して画像を描画しようという時に用いるインターフェース、すなわちリモコンとかゲームのコントローラーとかパソコンのマウスとかキーボードみたいなものです。
プログラマがディスプレイに対して画像を描画しようという時は、GraphicsAPIをどのように使用するかをこねくりまわして考えるだけでよく、逆にそれ以上のことは考えなくてよくなっているものです。

換言すればGraphicsAPIにできないことは代替手段を用いるか、自分で実装するか、あるいは諦めるしかありません。
例えばディスプレイの1つのドットを狙い撃つSetPixel()のような関数は、APIによっては存在したりしなかったりします。3D描画用のAPIであるDirectXやOpenGLは、その様式にのっとればGPUとやり取りして3D用のデータを二次元ディスプレイの座標に変換して3Dに見えるように表示するまでやってくれます。2D描画用のAPIを用いて同じことをやろうとすると、不可能ではないがとてもめんどくさいことになります。

GraphicsAPIは環境によって異なりますが、今はhtmlとJavaScriptを用い、かつ流れ的にCanvasをもちいているのでそれを使います。


今の文脈で、我々が描画したいデータというのはこれです。

        const gameMap = new Map(10, 10);

マップクラス。

	class Map {
	    constructor(w, h) {
	        this.Width = w;
	        this.Height = h;
	        this.GameBoard = Array.from({ length: this.Height }, () => Array(this.Width).fill([]));
	        this.MapObjects = [];

今、ChatGPTの書いてきたものをそのまま使っているので、描画すべきデータがGameBoardとMapObjectsの二種類あります。
重要なのは『なにを』『どうやって』表示するかであって、完全な正解はありません。

MapObjectをゲーム画面に並べてRPGのマップを構築したいのであれば、
GameBoardをy,x,zで走査してDrawしていけば目的を達成できます。
存在するMapObjectの中から条件に合致するものを抽出した上で並べて表示という操作になるとMapObjects[]を利用したほうがはるかに楽です。

現状MapObjectは(x,y)座標しかもってませんので、マップ上に重ねて表示するということをしたい場合はGameBoardを使用するしかありません。
MapObjectにzプロパティを追加し、かつあらかじめ配列内でzに関してソートしておけばMapObjects[]でも同じことはできるでしょう。
これは現状三次元配列であるGameBoardと、一次元配列であるMapObjects[]のデータ構造の違いです。

現状MapObjectにzプロパティを追加し、配列内でzに関してソートさえするならば、GameBoardとMapObjects[]は相互に変換可能であって、データとしてはどちらか一つに統一することは可能です。

『どうやって』部分、Mapクラスのdraw()
context2dはCanvas。ここでのGraphics API

	    draw(context2d) {
	        // Draw background
	        context2d.fillStyle = 'white';
	        context2d.fillRect(0, 0, this.Width * 32, this.Height * 32);
	        // Draw grid
	        context2d.strokeStyle = 'black';
	        context2d.lineWidth = 1;
	        for (let y = 0; y <= this.Height; y++) {
	            context2d.beginPath();
	            context2d.moveTo(0, y * 32);
	            context2d.lineTo(this.Width * 32, y * 32);
	            context2d.stroke();
	        }
	        for (let x = 0; x <= this.Width; x++) {
	            context2d.beginPath();
	            context2d.moveTo(x * 32, 0);
	            context2d.lineTo(x * 32, this.Height * 32);
	            context2d.stroke();
	        }
	        // Draw objects from bottom to top
	        for (let y = 0; y < this.Height; y++) {
	            for (let x = 0; x < this.Width; x++) {
	                const objectsInCell = this.GameBoard[y][x];
	                for (const obj of objectsInCell) {
	                    obj.draw(context2d);
	                }
	            }
	        }
	    }//draw

個々のオブジェクトのdraw()の例。

	class Block extends MapObject {
	    constructor(x, y) {
	        super(x, y);
	        this.onObjectEnter = (object) => {
	            // Block the object from entering the block
	            return false;
	        };
	    }

	    draw(context2d) {
	        context2d.fillStyle = 'black';
	        context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
	    }
	}

各種描画関数(fillRectのような)は、Canvasの原点(0,0)を基準としていることがわかります。
というのは、より強調すると、現状の実装ではこれら描画関数は描画の座標に負値を取らないということです。fillRect(-10,-20,w,h)のようにはならない。
なぜならCanvasの描画関数使用時にMapObjectの(x,y)座標を参考にしているからで、MapObjectは初期化時に負値を代入されていないし、PlayerオブジェクトはMove時にGameBoardのインデックスを移動するからです。

//Player Move関数内	          
            if (gameMap.triggerEvent(newX, newY, this)) {
	            gameMap.removeObject(this);  // 追加: 古いセルからプレイヤーを削除
	            this.x = newX;
	            this.y = newY;
	            gameMap.addObject(this);     // 追加: 新しいセルにプレイヤーを追加
	        }
//Mapクラス
	    addObject(mapObject) {
	        if (mapObject instanceof MapObject) {
	            this.MapObjects.push(mapObject);
	            this.GameBoard[mapObject.y][mapObject.x] = [...this.GameBoard[mapObject.y][mapObject.x], mapObject];
	        }
	    }

現状の実装ではPlayerオブジェクトはGameBoardの二次元配列上しか移動できないし、やるとエラーで止まります。(GameBoard[-1][1]のようなアクセスでエラーとなる)

この仕様は描画したいデータと、Graphics APIの描画領域のサイズが一致している時はなんの問題もありません。
あるいは左上が一致しているだけでも良いものです。

左上の一致

ところが描画したいデータの領域が大きく、Graphics APIの描画領域に収まらない時には工夫が必要となります。

あるいは描画したいデータの領域が小さく、それをGraphics APIの描画領域の指定位置(中央など)に配置したい時。

方法は二つ。
Graphics APIの描画のための基準点を別にもうけ、これに負値を許容する方法。
負値でエラーを出すのは配列操作のインデックスだけであり、fillRect自体は負値をとっても問題ありません。ゆえに

fillRect(origin.x+cell.x*32, origin.y+cell.y*32,w,h)

のような形をとれば問題は解決します。

もう一つはcanvas.translate()、ないしcanvas.setTransform()を用いる方法。
合計で三つですが、後ろ二つは似たようなもんです。

setTransform

gpt4

setTransform()メソッドは、Canvas 2D API の一部で、現在の変換行列を指定された行列で置き換えます。このメソッドは、描画するオブジェクトの位置、スケール、回転を制御します。

setTransform()メソッドのシグネチャは次のとおりです:

ctx.setTransform(a, b, c, d, e, f);

各パラメータは次の通りです:

  • a (scaleX): 水平方向のスケーリング

  • b (skewY): 垂直方向のスキュー

  • c (skewX): 水平方向のスキュー

  • d (scaleY): 垂直方向のスケーリング

  • e (translateX): 水平方向の移動

  • f (translateY): 垂直方向の移動

この行列は次の形式で表されます:

[ a c e ]
[ b d f ]
[ 0 0 1 ]

これらの値は、描画されるオブジェクトの位置、大きさ、形状に影響を与えます。例えば、次のコードは、50x50の四角形を画面の中央に描画します:

var canvas = document.getElementById('myCanvas');
var ctx = canvas.getContext('2d');

ctx.setTransform(1, 0, 0, 1, canvas.width / 2, canvas.height / 2);
ctx.fillRect(-25, -25, 50, 50);

このコードでは、setTransform()を使用して描画の原点をキャンバスの中央に移動しています。その結果、fillRect()メソッドで描画される四角形はキャンバスの中央に配置されます。

なお、setTransform()メソッドを呼び出すと、それまでに設定されていた全ての変換がリセットされる点に注意してください。変換をリセットせずに追加の変換を適用したい場合は、代わりにtransform()メソッドを使用します。

回転

setTransform() メソッドを使用して図形を回転させるには、変換行列の a, b, c, d パラメータを回転行列として設定します。

2D 回転行列は以下のように表現されます:

[  cos(θ) sin(θ) 0 ]
[ -sin(θ) cos(θ) 0 ]
[    0      0    1 ]

ここで、 θ は回転角度を表します。
したがって、 setTransform() メソッドのパラメータ a, b, c, d は次のようになります:

  • a = cos(θ)

  • b = sin(θ)

  • c = -sin(θ)

  • d = cos(θ)

以下は、キャンバスの中心を原点とした図形を45度回転させる例です:

var canvas = document.getElementById('myCanvas');
var ctx = canvas.getContext('2d');

var angleInDegrees = 45;
var angleInRadians = angleInDegrees * Math.PI / 180;

ctx.setTransform(Math.cos(angleInRadians), Math.sin(angleInRadians), -Math.sin(angleInRadians), Math.cos(angleInRadians), canvas.width / 2, canvas.height / 2);
ctx.fillRect(-25, -25, 50, 50);

このコードは、変換行列を回転行列として設定し、原点をキャンバスの中心に移動します。その結果、fillRect() メソッドで描画される四角形はキャンバスの中心に配置され、45度回転します。

回転、拡大縮小、平行移動

回転、スケーリング、平行移動を同時に制御する場合、それぞれの変換を組み合わせた変換行列を setTransform() メソッドに渡すことができます。

変換行列は以下のように表現されます:

[ scaleX * cos(θ)  -scaleY * sin(θ)  translateX ]
[ scaleX * sin(θ)   scaleY * cos(θ)  translateY ]
[       0                0               1      ]

ここで、 scaleXscaleY はそれぞれ x 方向と y 方向のスケール係数、 θ は回転角度、 translateXtranslateY はそれぞれ x 方向と y 方向の移動量を表します。
したがって、 setTransform() メソッドのパラメータ a, b, c, d, e, f は次のようになります:

  • a = scaleX * cos(θ)

  • b = scaleX * sin(θ)

  • c = -scaleY * sin(θ)

  • d = scaleY * cos(θ)

  • e = translateX

  • f = translateY

以下は、キャンバスの中心を原点とした図形を45度回転させ、x 方向に2倍、y 方向に0.5倍のスケールで描画し、さらに x 方向に100ピクセル、y 方向に150ピクセル移動する例です:

var canvas = document.getElementById('myCanvas');
var ctx = canvas.getContext('2d');

var angleInDegrees = 45;
var angleInRadians = angleInDegrees * Math.PI / 180;
var scaleX = 2;
var scaleY = 0.5;
var translateX = 100;
var translateY = 150;

ctx.setTransform(scaleX * Math.cos(angleInRadians), scaleX * Math.sin(angleInRadians), -scaleY * Math.sin(angleInRadians), scaleY * Math.cos(angleInRadians), translateX, translateY);
ctx.fillRect(-25, -25, 50, 50);

このコードは、変換行列をスケーリング、回転、平行移動の組み合わせとして設定します。その結果、fillRect() メソッドで描画される四角形は45度回転し、x 方向に2倍、y 方向に0.5倍のスケールで描画され、さらに x 方向に100ピクセル、y 方向に150ピクセル移動します。

その場回転

スキューも含めて、これらの変換はその変換の順序が変わると結果が変わることがあります。その場合はtransform()を用います。

通常回転操作は原点を中心に行われるため、適当にやってると描画領域でないところに図形が飛んで行ってどっかに消えます。

図形をその中心、あるいは重心を中心に回転、スケーリング、平行移動するには、以下の手順を踏むことが一般的です:

  1. まず、描画の原点を図形の重心に移動します。

  2. 次に、所望のスケーリングと回転を適用します。

  3. 最後に、図形を所望の位置に移動します。

以下は、この手順を踏んだコードの一例です。ここでは、50x50 の四角形をその重心を中心に45度回転させ、x 方向に2倍、y 方向に0.5倍のスケールで描画し、さらに x 方向に100ピクセル、y 方向に150ピクセル移動します:

var canvas = document.getElementById('myCanvas');
var ctx = canvas.getContext('2d');

var angleInDegrees = 45;
var angleInRadians = angleInDegrees * Math.PI / 180;
var scaleX = 2;
var scaleY = 0.5;
var translateX = 100;
var translateY = 150;

// オブジェクトのサイズ
var objectWidth = 50;
var objectHeight = 50;

// 図形の重心を中心にスケーリングと回転を適用
ctx.setTransform(scaleX * Math.cos(angleInRadians), scaleX * Math.sin(angleInRadians), -scaleY * Math.sin(angleInRadians), scaleY * Math.cos(angleInRadians), 0, 0);

// 図形を描画(原点は現在図形の重心)
ctx.fillRect(-objectWidth / 2, -objectHeight / 2, objectWidth, objectHeight);

// 重心を基準に平行移動
ctx.setTransform(1, 0, 0, 1, translateX + objectWidth / 2, translateY + objectHeight / 2);

// 再度同じ図形を描画
ctx.fillRect(-objectWidth / 2, -objectHeight / 2, objectWidth, objectHeight);

このコードは、最初に図形の重心を原点に設定してスケーリングと回転を適用し、その後、新しい原点を設定して図形を所望の位置に移動します。その結果、2つの四角形が描画されます。最初の四角形は原点(つまりキャンバスの左上)を中心に変換が適用され、2つ目の四角形はその重心を中心に変換が適用されています。

Canvas

Canvas自体の位置はhtmlやcssによって制御され、ブラウザの描画領域に配置されます。

以下gpt4

HTMLの<canvas>要素は、JavaScriptと共に使用することで、グラフィックスの描画や画像操作などを行うための領域を提供します。これは、2Dゲーム、グラフやチャートの描画、画像エフェクトの作成など、様々な目的で使用することができます。

基本的な使用方法は次のようになります。

<canvas id="myCanvas" width="500" height="500"></canvas>

このHTMLコードは、500x500ピクセルのキャンバス領域を作成します。このキャンバスに対してJavaScriptを使用して描画を行います。以下にその例を示します。

// Canvas要素を取得
var canvas = document.getElementById('myCanvas');

// 2Dレンダリングコンテキストを取得
var ctx = canvas.getContext('2d');

// 四角形を描画
ctx.fillStyle = 'red'; // 塗りつぶしの色を指定
ctx.fillRect(50, 50, 100, 100); // 四角形を描画(x座標, y座標, 幅, 高さ)

上記のJavaScriptコードは、赤い100x100ピクセルの四角形をキャンバスの(50, 50)の位置に描画します。

また、HTML5からはWebGLというAPIが追加され、これを使うと<canvas>要素で3Dグラフィックスも扱うことが可能になりました。

なお、<canvas>要素は、対応していない古いブラウザやJavaScriptが無効化されている環境を考慮すると、その中にフォールバックコンテンツを記述することが推奨されます。これは、<canvas>要素がサポートされていない場合に表示されるコンテンツです。

<canvas id="myCanvas" width="500" height="500">
  ご使用のブラウザはcanvasをサポートしていません。
</canvas>

これらの基本的な情報を元に、具体的な要件に応じて<canvas>要素を使用することができます。

カメラフォーカス

一般的にカメラやフォーカスは3Dの文脈で用いられるため、ここでは注釈が必要です。ここでいうカメラないしフォーカスとは描画の基準点であって、例えばプレイヤーキャラクターをGraphics APIの描画領域の中心に常に配置したいと考えた場合などの基準です。

描画領域の中心に常に据えるという考えならば、その基準はフォーカスと名付けるべきです。
プレイヤーキャラクターを中心に、半径rの範囲まで描画するとした時、その基準をカメラと名付けることは別に間違いではありません。
また、プレイヤーを中心とすることを前提とするならば、新たな概念を作る必要すらないものです。
逆に、カメラもフォーカスも両方用意するという方式でもかまいません。

ここでは回転、拡大縮小、平行移動に関する情報を有するためにカメラクラスを作り、フォーカスの役割もカメラに持たせました。そういう風にChatGPTに作ってもらいました。また、カメラはMapObjectとしました。


プレイヤーを画面の中心に据える

基本的にはプレイヤーの座標引くことの画面の中心の座標分だけ
Canvasを平行移動、すなわちtranslate()します。

ChatGPT君の答え。
図とは矢印の向きが逆ですが、言ってることは同じです。
この変換はカメラを画面中心とするための平行移動、およびスケールが適用されています。

             //回転なし
             applyTransform(context2d, mapWindow) {
                 const offsetX = mapWindow.width / 2 - this.x * mapWindow.Map.CellWidth * this.Scale;
                 const offsetY = mapWindow.height / 2 - this.y * mapWindow.Map.CellHeight * this.Scale;
                 context2d.setTransform(this.Scale, 0, 0, this.Scale, offsetX, offsetY);
             }

回転込みの場合。

            applyTransform(context2d, mapWindow) { 
                // 画面の中心に移動
                context2d.translate(mapWindow.width / 2, mapWindow.height / 2);            
                // 回転を適用
                context2d.rotate(this.Rot);            
                // スケーリングを適用
                context2d.scale(this.Scale, this.Scale);            
                // カメラ(プレイヤー)の位置に戻る
                context2d.translate(-this.x * mapWindow.Map.CellWidth, -this.y * mapWindow.Map.CellHeight);
            }

translateなどは変換行列に変更を加えるため、逐一元に戻します。

        class MapWindow {
            constructor(width, height, map) {
                this.width = width;
                this.height = height;
                this.Map = map;
            }

            draw(context2d, camera) {
                context2d.clearRect(0, 0, this.width, this.height);
                camera.applyTransform(context2d, this);
                this.Map.draw(context2d, camera);
                //translate, scale, rotateが変更した変換行列を元に戻す。
                context2d.setTransform(1, 0, 0, 1, 0, 0);
            }
        }

以下ソース。
あと、ファイル分割しました。してもらいました。

メッセージ表示関数はバグってます。これはimport, exportの関係です。
メッセージ系をhtml,cssで制御するのか、あるいはcanvas使うのか(適当に使うとメッセージウィンドウごと回転したりします)、あるいはウィンドウ用のcanvasを新たに用意するのか、その辺も考えなければなりません。

ソース:カメラうんたら

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Game</title>
    <style>
    
		:root {
		  --main-window-width: 500px;
		  --main-window-height: 500px;
		  --game-canvas-width: 320px;
		  --game-canvas-height: 320px;
		}    
		
		#mainWindow {
		    position: relative;
		    display: inline-block;
		    width: var(--main-window-width);
		    height: var(--main-window-height);
		}

		#gameCanvas {
		    position: absolute;
		    top: 0;
		    left: 0;
		    width: var(--game-canvas-width); 
		    height: var(--game-canvas-height); 
		}

		#messageWindow {
		    position: absolute;
		    bottom: 0;
		    left: 0;
		    width: var(--game-canvas-width); 
		    height: calc(var(--main-window-height) - var(--game-canvas-height)); 
		    background-color: lightblue;
		}

		#eventWindow {
		    position: absolute;
		    top: 0;
		    left: 320px; 
		    width: calc(var(--main-window-width) - var(--game-canvas-width)); 
		    height: var(--main-window-height);
		    background-color: lightgreen;
		}

    </style>
</head>

<body>
    <div id="mainWindow">
        <canvas id="gameCanvas"></canvas>
        <div id="eventWindow"></div>
        <div id="messageWindow"></div>
    </div>

<script type="module" src="main.js"></script>

</body>
</html>

main.js




import { MapObject, CellBase, Block, Blank, BattleObject, Monster, Player } from './ObjectClasses.js';
import { Camera, MapWindow } from './WindowClasses.js';


function showMessage(message) {
	messageWindow.textContent = message;
}


let objectIdCounter = 0;

class Map {
	constructor(w, h, cw, ch) {
		this.ColumnCount = w;
		this.RowCount = h;
		this.CellWidth = cw;
		this.CellHeight = ch;
		this.GameBoard = Array.from({ length: this.RowCount }, () => Array(this.ColumnCount).fill([]));
		this.MapObjects = [];

		// Fill the map with CellBase objects
		for (let y = 0; y < this.RowCount; y++) {
			for (let x = 0; x < this.ColumnCount; x++) {
				const cell = new CellBase(x, y);
				this.addObject(cell);
			}
		}

		// Surround the map with Block objects
		for (let y = 0; y < this.RowCount; y++) {
			for (let x = 0; x < this.ColumnCount; x++) {
				if (y === 0 || y === this.RowCount - 1 || x === 0 || x === this.ColumnCount - 1) {
					const block = new Block(x, y);
					this.addObject(block);
				}
			}
		}

		// Add some Block objects randomly on the map
		const randomBlockPositions = [
			{ x: 3, y: 3 },
			{ x: 6, y: 2 },
			{ x: 2, y: 5 }
		];
		for (const position of randomBlockPositions) {
			const block = new Block(position.x, position.y);
			this.addObject(block);
		}



		// Add a Blank object with a "test" message at a random accessible position
		let randomX, randomY;
		do {
			randomX = Math.floor(Math.random() * (this.ColumnCount - 2)) + 1;
			randomY = Math.floor(Math.random() * (this.RowCount - 2)) + 1;
		} while (this.GameBoard[randomY][randomX] instanceof Block);
		const blank = new Blank(randomX, randomY);
		this.addObject(blank);

	}//Map_constructor

	get GridWidth()	{
		return this.ColumnCount * this.CellWidth;
	}
	get GridHeight() {
		return this.RowCount * this.CellHeight;
	}

	addObject(mapObject) {
		if (mapObject instanceof MapObject) {
			this.MapObjects.push(mapObject);
			this.GameBoard[mapObject.y][mapObject.x] = [...this.GameBoard[mapObject.y][mapObject.x], mapObject];
		}
	}

	removeObject(mapObject) {
		if (mapObject instanceof MapObject) {
			const index = this.MapObjects.indexOf(mapObject);
			if (index > -1) {
				this.MapObjects.splice(index, 1);
				this.GameBoard[mapObject.y][mapObject.x] = this.GameBoard[mapObject.y][mapObject.x].filter(obj => obj !== mapObject);
			}
		}
	}

	draw(context2d, camera) {
		// Draw background
		context2d.fillStyle = 'white';
		context2d.fillRect(0, 0, this.GridWidth, this.CellHeight);
		// Draw grid
		context2d.strokeStyle = 'black';
		context2d.lineWidth = 1;
		for (let y = 0; y <= this.RowCount; y++) {
			context2d.beginPath();
			context2d.moveTo(0, y * 32);
			context2d.lineTo(this.ColumnCount * 32, y * 32);
			context2d.stroke();
		}
		for (let x = 0; x <= this.ColumnCount; x++) {
			context2d.beginPath();
			context2d.moveTo(x * 32, 0);
			context2d.lineTo(x * 32, this.RowCount * 32);
			context2d.stroke();
		}
		// Draw objects from bottom to top
		for (let y = 0; y < this.RowCount; y++) {
			for (let x = 0; x < this.ColumnCount; x++) {
				const objectsInCell = this.GameBoard[y][x];
				for (const obj of objectsInCell) {
					obj.draw(context2d);
				}
			}
		}

		
	}//draw



	triggerEvent(x, y, object) {
		const objectsInCell = this.GameBoard[y][x];
		for (let i = objectsInCell.length - 1; i >= 0; i--) {
			const mapObject = objectsInCell[i];
			if (mapObject) {
				const result = mapObject.triggerEvent(object);
				if (!result) {
					return false;
				}
			}
		}
		return true; // Default behavior is to allow the object to enter
	}

}//Map



const canvas = document.getElementById('gameCanvas');
canvas.width = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--game-canvas-width'));
canvas.height = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--game-canvas-height'));

const GridRowCount = 10;
const GridColCount = 10;
const CellWidth = 32;
const CellHeight = 32;
const context2d = canvas.getContext('2d');
const gameMap = new Map(GridColCount, GridRowCount, CellWidth, CellHeight);
const player = new Player(4, 4);
gameMap.addObject(player);
const monster = new Monster(6, 6, 1, "Monster A", 100, 20, 10);
gameMap.addObject(monster);
monster.battleObject.addAction("attack", function () {
	console.log(`${this.name} attacks!`);
});

const mapWindow = new MapWindow(canvas.width, canvas.height, gameMap);
const camera = new Camera(player.x, player.y);

let lastFrameTime = performance.now();
const targetFrameDuration = 1000 / 60;

const keyStates = {};

function gameLoop(currentTime) {
	const deltaTime = currentTime - lastFrameTime;
	if (deltaTime >= targetFrameDuration) {
		update(deltaTime);
		draw();
		lastFrameTime = currentTime;
	}
	requestAnimationFrame(gameLoop);
}

function update(deltaTime) {
	for (const key in keyStates) {
		const state = keyStates[key];
		if (state === 'pressed') {
			const direction = directionKeyMap[key];
			if (direction) {
				player.move(direction, gameMap);
			}
			keyStates[key] = 'holding';
		}
	}

	// Update the camera's position to follow the player
	camera.x = player.x;
	camera.y = player.y;
}

function keydownHandler(event) {
	if (!keyStates[event.code]) {
		keyStates[event.code] = 'pressed';
	}
}

function keyupHandler(event) {
	delete keyStates[event.code];
}

function draw() {
	mapWindow.draw(context2d, camera);
}



const directionKeyMap = {
	'Numpad1': 1,
	'Numpad2': 2,
	'Numpad3': 3,
	'Numpad4': 4,
	'Numpad6': 6,
	'Numpad7': 7,
	'Numpad8': 8,
	'Numpad9': 9,

	'ArrowDown': 2,
	'ArrowUp': 8,
	'ArrowLeft': 4,
	'ArrowRight': 6,

};

document.addEventListener("keydown", keydownHandler);
document.addEventListener("keyup", keyupHandler);
requestAnimationFrame(gameLoop);

ObjectClasses.js



	class MapObject {
	    constructor(x, y) {
	        this.x = x;
	        this.y = y;
	        this.onObjectEnter = null;
	    }

	    draw(context2d) {
	     
	    }

	    triggerEvent(object) {
	        if (this.onObjectEnter) {
	            return this.onObjectEnter(object);
	        }
	        return true; // Default behavior is to allow the object to enter
	    }
	}

	class CellBase extends MapObject {
	    constructor(x, y) {
	        super(x, y);
	    }
	    draw(context2d) {
	        context2d.fillStyle = 'gray';
	        context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
	    }
	}

	class Block extends MapObject {
	    constructor(x, y) {
	        super(x, y);
	        this.onObjectEnter = (object) => {
	            // Block the object from entering the block
	            return false;
	        };
	    }

	    draw(context2d) {
	        context2d.fillStyle = 'black';
	        context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
	    }
	}
	
	class Blank extends MapObject {
	    constructor(x, y) {
	        super(x, y);
	        this.onObjectEnter = (object) => {
	            //showMessage('test');
	            return true; // Allow the object to enter
	        };
	    }
	}	
	

	
	
    class BattleObject {
        constructor(id, name, hp, str, def) {
            this.id = id;
            this.name = name;
            this.hp = hp;
            this.str = str;
            this.def = def;
            this.actions = {};
        }

        addAction(actionName, actionFunction) {
            this.actions[actionName] = actionFunction;
        }
    }
    
    class Monster extends MapObject {
        constructor(x, y, id, name, hp, str, def) {
            super(x, y);
            this.battleObject = new BattleObject(id, name, hp, str, def);
        }

        draw(context2d) {
            context2d.fillStyle = 'red';
            context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
        }
    }    



	
    	

	class Player extends MapObject {
	    constructor(x, y) {
	        super(x, y);
		    }

	    move(direction, gameMap) {
	        let newX = this.x;
	        let newY = this.y;

	        switch (direction) {
	            case 1:
	                newX -= 1;
	                newY += 1;
	                break;
	            case 2:
	                newY += 1;
	                break;
	            case 3:
	                newX += 1;
	                newY += 1;
	                break;
	            case 4:
	                newX -= 1;
	                break;
	            case 6:
	                newX += 1;
	                break;
	            case 7:
	                newX -= 1;
	                newY -= 1;
	                break;
	            case 8:
	                newY -= 1;
	                break;
	            case 9:
	                newX += 1;
	                newY -= 1;
	                break;
	            default:
	                console.log("Invalid direction. Please enter a valid number (1-9, excluding 5).");
	                break;
	        }//switch

	        if (gameMap.triggerEvent(newX, newY, this)) {
	            gameMap.removeObject(this);
	            this.x = newX;
	            this.y = newY;
	            gameMap.addObject(this);
	        }
	    }//move

	    draw(context2d) {
	        context2d.fillStyle = 'blue';
	        context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
	    }
	    

        	    
	}//player
	


export { MapObject, CellBase, Block, Blank, BattleObject, Monster, Player };

WindowClasses.js


      
import { MapObject } from './ObjectClasses.js';
      
      
      class Camera extends MapObject {
            constructor(x, y, offset=[0,0], scale=1, rot=0) {
                super(x, y);
                this.Offset=offset;
                this.Scale = 1;
                this.Rot = 0;
            }

            //回転なし
            // applyTransform(context2d, mapWindow) {
            //     const offsetX = mapWindow.width / 2 - this.x * mapWindow.Map.CellWidth * this.Scale;
            //     const offsetY = mapWindow.height / 2 - this.y * mapWindow.Map.CellHeight * this.Scale;
            //     context2d.setTransform(this.Scale, 0, 0, this.Scale, offsetX, offsetY);
            // }
            
            applyTransform(context2d, mapWindow) {
                const offsetX = mapWindow.width / 2 - this.x * mapWindow.Map.CellWidth * this.Scale;
                const offsetY = mapWindow.height / 2 - this.y * mapWindow.Map.CellHeight * this.Scale;
            
                // 変換をリセット
                context2d.setTransform(1, 0, 0, 1, 0, 0);
            
                // 画面の中心に移動
                context2d.translate(mapWindow.width / 2, mapWindow.height / 2);
            
                // 回転を適用
                context2d.rotate(this.Rot);
            
                // スケーリングを適用
                context2d.scale(this.Scale, this.Scale);
            
                // カメラ(プレイヤー)の位置に戻る
                context2d.translate(-this.x * mapWindow.Map.CellWidth, -this.y * mapWindow.Map.CellHeight);
            }
            
                        
        }


        class MapWindow {
            constructor(width, height, map) {
                this.width = width;
                this.height = height;
                this.Map = map;
            }

            draw(context2d, camera) {
                context2d.clearRect(0, 0, this.width, this.height);
                camera.applyTransform(context2d, this);
                this.Map.draw(context2d, camera);
                context2d.setTransform(1, 0, 0, 1, 0, 0);
            }
        }


export { Camera, MapWindow };


ソース:htmlとcss部分をcanvsに

そろそろChatGPT4君の反応が悪いというか、入力過多。
以降はがんばって自分で書きましょう。

import系はたぶんローカルサーバーを使わないと駄目と思われます。
VSCodeならLive Serverを使ってください。

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Game</title>
    <style>		
		#mainWindow {	
		    width: 500px;
		    height: 500px;
		}
    </style>
</head>

<body>
    <canvas id="mainWindow"></canvas>

<script type="module" src="main.js"></script>

</body>
</html>

main.js


import { MapObject, CellBase, Block, Blank, BattleObject, Monster, Player } from './ObjectClasses.js';
import { Camera, MapWindow, MessageWindow, EventWindow  } from './WindowClasses.js';


function showMessage(message) {
	messageWindow.textContent = message;
}


let objectIdCounter = 0;

class Map {
	constructor(w, h, cw, ch) {
		this.ColumnCount = w;
		this.RowCount = h;
		this.CellWidth = cw;
		this.CellHeight = ch;
		this.GameBoard = Array.from({ length: this.RowCount }, () => Array(this.ColumnCount).fill([]));
		this.MapObjects = [];

		// Fill the map with CellBase objects
		for (let y = 0; y < this.RowCount; y++) {
			for (let x = 0; x < this.ColumnCount; x++) {
				const cell = new CellBase(x, y);
				this.addObject(cell);
			}
		}

		// Surround the map with Block objects
		for (let y = 0; y < this.RowCount; y++) {
			for (let x = 0; x < this.ColumnCount; x++) {
				if (y === 0 || y === this.RowCount - 1 || x === 0 || x === this.ColumnCount - 1) {
					const block = new Block(x, y);
					this.addObject(block);
				}
			}
		}

		// Add some Block objects randomly on the map
		const randomBlockPositions = [
			{ x: 3, y: 3 },
			{ x: 6, y: 2 },
			{ x: 2, y: 5 }
		];
		for (const position of randomBlockPositions) {
			const block = new Block(position.x, position.y);
			this.addObject(block);
		}



		// Add a Blank object with a "test" message at a random accessible position
		let randomX, randomY;
		do {
			randomX = Math.floor(Math.random() * (this.ColumnCount - 2)) + 1;
			randomY = Math.floor(Math.random() * (this.RowCount - 2)) + 1;
		} while (this.GameBoard[randomY][randomX] instanceof Block);
		const blank = new Blank(randomX, randomY);
		this.addObject(blank);

	}//Map_constructor

	get GridWidth()	{
		return this.ColumnCount * this.CellWidth;
	}
	get GridHeight() {
		return this.RowCount * this.CellHeight;
	}

	addObject(mapObject) {
		if (mapObject instanceof MapObject) {
			this.MapObjects.push(mapObject);
			this.GameBoard[mapObject.y][mapObject.x] = [...this.GameBoard[mapObject.y][mapObject.x], mapObject];
		}
	}

	removeObject(mapObject) {
		if (mapObject instanceof MapObject) {
			const index = this.MapObjects.indexOf(mapObject);
			if (index > -1) {
				this.MapObjects.splice(index, 1);
				this.GameBoard[mapObject.y][mapObject.x] = this.GameBoard[mapObject.y][mapObject.x].filter(obj => obj !== mapObject);
			}
		}
	}

	draw(context2d, camera) {
		// Draw background
		context2d.fillStyle = 'white';
		context2d.fillRect(0, 0, this.GridWidth, this.CellHeight);
		// Draw grid
		context2d.strokeStyle = 'black';
		context2d.lineWidth = 1;
		for (let y = 0; y <= this.RowCount; y++) {
			context2d.beginPath();
			context2d.moveTo(0, y * 32);
			context2d.lineTo(this.ColumnCount * 32, y * 32);
			context2d.stroke();
		}
		for (let x = 0; x <= this.ColumnCount; x++) {
			context2d.beginPath();
			context2d.moveTo(x * 32, 0);
			context2d.lineTo(x * 32, this.RowCount * 32);
			context2d.stroke();
		}
		// Draw objects from bottom to top
		for (let y = 0; y < this.RowCount; y++) {
			for (let x = 0; x < this.ColumnCount; x++) {
				const objectsInCell = this.GameBoard[y][x];
				for (const obj of objectsInCell) {
					obj.draw(context2d);
				}
			}
		}

		
	}//draw



	triggerEvent(x, y, object) {
		const objectsInCell = this.GameBoard[y][x];
		for (let i = objectsInCell.length - 1; i >= 0; i--) {
			const mapObject = objectsInCell[i];
			if (mapObject) {
				const result = mapObject.triggerEvent(object);
				if (!result) {
					return false;
				}
			}
		}
		return true; // Default behavior is to allow the object to enter
	}

}//Map



const gameWindowWidth = 320;
const gameWindowHeight= 320;

// const canvas = document.getElementById('gameCanvas');
// canvas.width = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--game-canvas-width'));
// canvas.height = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--game-canvas-height'));

const mainWindow = document.getElementById('mainWindow');
mainWindow.width=500;
mainWindow.height=500;
const mainContext2d = mainWindow.getContext('2d');

const GridRowCount = 10;
const GridColCount = 10;
const CellWidth = 32;
const CellHeight = 32;

const gameMap = new Map(GridColCount, GridRowCount, CellWidth, CellHeight);
const player = new Player(4, 4);
gameMap.addObject(player);
const monster = new Monster(6, 6, 1, "Monster A", 100, 20, 10);
gameMap.addObject(monster);
monster.battleObject.addAction("attack", function () {
	console.log(`${this.name} attacks!`);
});

const mapWindow = new MapWindow(0, 0, 0, gameWindowWidth, gameWindowHeight, gameMap);
const camera = new Camera(player.x, player.y);
const messageWindow = new MessageWindow(1, mapWindow.left, mapWindow.width, mapWindow.width, mainWindow.height-mapWindow.height)
const eventWindow = new EventWindow(2, mapWindow.right, mapWindow.top, mainWindow.width-mapWindow.width, mainWindow.height)

let lastFrameTime = performance.now();
const targetFrameDuration = 1000 / 60;

const keyStates = {};

function gameLoop(currentTime) {
	const deltaTime = currentTime - lastFrameTime;
	if (deltaTime >= targetFrameDuration) {
		update(deltaTime);
		draw();
		lastFrameTime = currentTime;
	}
	requestAnimationFrame(gameLoop);
}

function update(deltaTime) {
	for (const key in keyStates) {
		const state = keyStates[key];
		if (state === 'pressed') {
			const direction = directionKeyMap[key];
			if (direction) {
				player.move(direction, gameMap);
			}
			keyStates[key] = 'holding';
		}
	}

	// Update the camera's position to follow the player
	camera.x = player.x;
	camera.y = player.y;
}

function keydownHandler(event) {
	if (!keyStates[event.code]) {
		keyStates[event.code] = 'pressed';
	}
}

function keyupHandler(event) {
	delete keyStates[event.code];
}

function draw() {
	
	mapWindow.draw(camera);
	messageWindow.draw();
	eventWindow.draw();

	mainContext2d.drawImage(mapWindow.canvas, mapWindow.x, mapWindow.y);
	mainContext2d.drawImage(messageWindow.canvas, messageWindow.x, messageWindow.y);
	mainContext2d.drawImage(eventWindow.canvas, eventWindow.x, eventWindow.y);
}



const directionKeyMap = {
	'Numpad1': 1,
	'Numpad2': 2,
	'Numpad3': 3,
	'Numpad4': 4,
	'Numpad6': 6,
	'Numpad7': 7,
	'Numpad8': 8,
	'Numpad9': 9,

	'ArrowDown': 2,
	'ArrowUp': 8,
	'ArrowLeft': 4,
	'ArrowRight': 6,

};

document.addEventListener("keydown", keydownHandler);
document.addEventListener("keyup", keyupHandler);
requestAnimationFrame(gameLoop);

ObjectClasses.js




	class MapObject {
	    constructor(x, y) {
	        this.x = x;
	        this.y = y;
	        this.onObjectEnter = null;
	    }

	    draw(context2d) {
	     
	    }

	    triggerEvent(object) {
	        if (this.onObjectEnter) {
	            return this.onObjectEnter(object);
	        }
	        return true; // Default behavior is to allow the object to enter
	    }
	}

	class CellBase extends MapObject {
	    constructor(x, y) {
	        super(x, y);
	    }
	    draw(context2d) {
	        context2d.fillStyle = 'gray';
	        context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
	    }
	}

	class Block extends MapObject {
	    constructor(x, y) {
	        super(x, y);
	        this.onObjectEnter = (object) => {
	            // Block the object from entering the block
	            return false;
	        };
	    }

	    draw(context2d) {
	        context2d.fillStyle = 'black';
	        context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
	    }
	}
	
	class Blank extends MapObject {
	    constructor(x, y) {
	        super(x, y);
	        this.onObjectEnter = (object) => {
	            //showMessage('test');
	            return true; // Allow the object to enter
	        };
	    }
	}	
	

	
	
    class BattleObject {
        constructor(id, name, hp, str, def) {
            this.id = id;
            this.name = name;
            this.hp = hp;
            this.str = str;
            this.def = def;
            this.actions = {};
        }

        addAction(actionName, actionFunction) {
            this.actions[actionName] = actionFunction;
        }
    }
    
    class Monster extends MapObject {
        constructor(x, y, id, name, hp, str, def) {
            super(x, y);
            this.battleObject = new BattleObject(id, name, hp, str, def);
        }

        draw(context2d) {
            context2d.fillStyle = 'red';
            context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
        }
    }    



	
    	

	class Player extends MapObject {
	    constructor(x, y) {
	        super(x, y);
		    }

	    move(direction, gameMap) {
	        let newX = this.x;
	        let newY = this.y;

	        switch (direction) {
	            case 1:
	                newX -= 1;
	                newY += 1;
	                break;
	            case 2:
	                newY += 1;
	                break;
	            case 3:
	                newX += 1;
	                newY += 1;
	                break;
	            case 4:
	                newX -= 1;
	                break;
	            case 6:
	                newX += 1;
	                break;
	            case 7:
	                newX -= 1;
	                newY -= 1;
	                break;
	            case 8:
	                newY -= 1;
	                break;
	            case 9:
	                newX += 1;
	                newY -= 1;
	                break;
	            default:
	                console.log("Invalid direction. Please enter a valid number (1-9, excluding 5).");
	                break;
	        }//switch

	        if (gameMap.triggerEvent(newX, newY, this)) {
	            gameMap.removeObject(this);
	            this.x = newX;
	            this.y = newY;
	            gameMap.addObject(this);
	        }
	    }//move

	    draw(context2d) {
	        context2d.fillStyle = 'blue';
	        context2d.fillRect(this.x * 32, this.y * 32, 32, 32);
	    }
	    

        	    
	}//player
	


export { MapObject, CellBase, Block, Blank, BattleObject, Monster, Player };

WindowClasses.js


      
import { MapObject } from './ObjectClasses.js';
      
      

class WindowBase {
    constructor(id, x, y, width, height) {
        this.x=x;
        this.y=y;
        this.canvas = document.createElement('canvas');
        this.canvas.id = id;
        this.canvas.width = width;
        this.canvas.height = height;
        this.context2d = this.canvas.getContext('2d');
        //document.body.appendChild(this.canvas);
    }

    get width(){
        return this.canvas.width;
    }
    get height(){
        return this.canvas.height;
    }

    get top(){
        return this.y;
    }
    get left(){
        return this.x;        
    }
    get right(){
        return this.x+this.canvas.width;
    }
    get bottom(){
        return this.y+this.canvas.height;
    }

    //x,yを他のwindowの境界に合わせる
    attachToTop(targetWindow){
        this.y=targetWindow.top-this.height;
    }
    attachToBottom(targetWindow){
        this.y=targetWindow.bottom;
    }
    attachToLeft(targetWindow){
        this.x=targetWindow.left-this.width;
    }
    attachToRight(targetWindow){
        this.x=targetWindow.right;
    }    

    

    clear() {
        this.context2d.clearRect(0, 0, this.canvas.width, this.canvas.height);
    }

    drawImage(img, x, y, width, height) {
        this.context2d.drawImage(img, x, y, width, height);
    }

    applyTransform(matrix) {
        this.context2d.transform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
    }

    draw() {
        this.clearWindow();
    }

    clearWindow() {
        // clear background
        this.context2d.fillStyle = 'white';
        this.context2d.fillRect(0, 0, this.width, this.height);
		// Draw edge
		this.context2d.strokeStyle = 'black';
		this.context2d.lineWidth = 2;
        this.context2d.strokeRect(0, 0, this.width, this.height);
    }
}

class MessageWindow extends WindowBase {
    constructor(id, x,y,width, height) {
        super(id, x,y,width, height);
    }

    showMessage(message, img) {
        this.clear();
        // Draw message text
        this.context2d.fillText(message, 10, 20);
        // Draw image
        if (img) this.drawImage(img, 30, 30, 50, 50);
    }


}

class EventWindow extends WindowBase {
    constructor(id, x,y,width, height) {
        super(id, x,y,width, height);
    }

    showEvent(event, img) {
        this.clear();
        // Draw event text
        this.context2d.fillText(event.description, 10, 20);
        // Draw image
        if (img) this.drawImage(img, 30, 30, 50, 50);
    }


}

      class Camera extends MapObject {
            constructor(x, y, offset=[0,0], scale=1, rot=0) {
                super(x, y);
                this.Offset=offset;
                this.Scale = 1;
                this.Rot = 0;
            }

            //回転なし
            // applyTransform(context2d, mapWindow) {
            //     const offsetX = mapWindow.width / 2 - this.x * mapWindow.Map.CellWidth * this.Scale;
            //     const offsetY = mapWindow.height / 2 - this.y * mapWindow.Map.CellHeight * this.Scale;
            //     context2d.setTransform(this.Scale, 0, 0, this.Scale, offsetX, offsetY);
            // }
            
            applyTransform(context2d, mapWindow) { 
                // 画面の中心に移動
                context2d.translate(mapWindow.width / 2, mapWindow.height / 2);            
                // 回転を適用
                context2d.rotate(this.Rot);            
                // スケーリングを適用
                context2d.scale(this.Scale, this.Scale);            
                // カメラ(プレイヤー)の位置に戻る
                context2d.translate(-this.x * mapWindow.Map.CellWidth, -this.y * mapWindow.Map.CellHeight);
            }
            
                        
        }


        class MapWindow extends WindowBase {
            constructor(id,x,y,width, height, map) {
                super(id, x,y,width, height);
                // this.width = width;
                // this.height = height;
                this.Map = map;
            }

            draw(camera) {
                this.clearWindow();
                camera.applyTransform(this.context2d, this);
                this.Map.draw(this.context2d, camera);               
                this.context2d.setTransform(1, 0, 0, 1, 0, 0);
            }
        }


export { Camera, MapWindow, MessageWindow, EventWindow };

この記事が気に入ったらサポートをしてみませんか?