0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Babylon.jsで8番出口を作った

Posted at

こんばんは、うえむーです。

最近はAIを使った業務改善や技術検証など、日々いろいろと模索しているのですが、
「たまには純粋におもしろいものを作りたい!」と思い立ち、
Babylon.jsで8番出口っぽいのを作ってみました。

完成したものがこちらです
https://babylonjs-two.vercel.app/the_exit_8/

スクリーンショット 2025-12-04 0.21.56(2) 1 (1).png

半分くらいは過去に自分で書いたコードを流用しつつ、
残りは Claude Code と ChatGPT を併用して実装しております。
以下では、仕掛けのいくつかを紹介します。

壁・テクスチャ周り

まずは世界観づくりから。
壁・床などのテクスチャ画像は ChatGPT に指示して生成しました。
怪しげな雰囲気が出るようにタイル調のデザインにしています。

skybox_nx.jpg

上記のテクスチャを利用して、怪しげな雰囲気が出るようにしました。

          const skybox = BABYLON.MeshBuilder.CreateBox("skyBox", {size: 1000}, scene);
          const skyboxMaterial = new BABYLON.StandardMaterial("skyBox", scene);
                skyboxMaterial.backFaceCulling = false;
          const skyboxTexture = "textures/skybox";

                skyboxMaterial.reflectionTexture = new BABYLON.CubeTexture(skyboxTexture, scene);
                skyboxMaterial.reflectionTexture.coordinatesMode = BABYLON.Texture.SKYBOX_MODE;
                skyboxMaterial.diffuseColor = new BABYLON.Color3(0, 0, 0);
                skyboxMaterial.specularColor = new BABYLON.Color3(0, 0, 0);
                skybox.material = skyboxMaterial;

防犯ポスター:目が追従する仕掛け

カーソルをぐりぐり動かすと、ポスターの目が追従します。
(本家にある「異変が起きると目がついてくるアレ」です!)

1_1.gif

実装としては、カーソル位置を正規化して、
ポスター内の眼球スプライトの位置を微妙にズラすことで実現しています。

          // 目を作成                    
          // テキスト用の平面を作成
          var myText = BABYLON.MeshBuilder.CreatePlane("textPlane", {width: 200, height: 200}, scene);
          myText.position.z = -0;
          myText.parent = cameraGroup;

          // テクスチャにテキストを描画
          var dynamicTexture = new BABYLON.DynamicTexture("dynamic texture", {width: 1000, height: 1000}, scene);
          dynamicTexture.hasAlpha = true;
          dynamicTexture.drawText("防犯カメラ", null, 580, "600 100px Arial", "#000", "transparent", true);
          dynamicTexture.drawText("作動中!", null, 700, "600 100px Arial", "#ff0000", "transparent", true);
          dynamicTexture.drawText("Security camera in operation", null, 900, "600 50px Arial", "#000", "transparent", true);

          // テキスト材質
          var textMaterial = new BABYLON.StandardMaterial("textMaterial", scene);
          textMaterial.diffuseTexture = dynamicTexture;
          textMaterial.backFaceCulling = false; // 両面表示

          myText.material = textMaterial;

          const box = BABYLON.MeshBuilder.CreateBox("box", {height: 200, width: 200, depth: 0.25}, scene);
          box.position.z = 1;
          box.parent = cameraGroup;

          const boxMaterial = new BABYLON.StandardMaterial("boxMaterial", scene);
          boxMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1); // 白色
          boxMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1); // 自己発光で明るく
          boxMaterial.backFaceCulling = false; // 両面をレンダリング
          box.material = boxMaterial;

          // 左目の楕円
          const leftEllipseblack = BABYLON.MeshBuilder.CreateSphere("leftEllipse", {diameterX: 11, diameterY: 17, diameterZ:1}, scene);
          leftEllipseblack.scaling.x = 5;
          leftEllipseblack.scaling.y = 2;
          leftEllipseblack.scaling.z = 0.5;
          leftEllipseblack.position.x = -40;
          leftEllipseblack.position.y = 40;
          leftEllipseblack.position.z = 0.5;
          leftEllipseblack.parent = cameraGroup;

          // 右目の楕円
          const rightEllipseblack = BABYLON.MeshBuilder.CreateSphere("rightEllipseblack", {diameterX: 11, diameterY: 17, diameterZ:1}, scene);
          rightEllipseblack.scaling.x = 5;
          rightEllipseblack.scaling.y = 2;
          rightEllipseblack.scaling.z = 0.5;
          rightEllipseblack.position.x = 40;
          rightEllipseblack.position.y = 40;
          rightEllipseblack.position.z = 0.5;
          rightEllipseblack.parent = cameraGroup;

          // 楕円の材質を設定
          const ellipseMaterialblack = new BABYLON.StandardMaterial("ellipseMaterialblack", scene);
          ellipseMaterialblack.diffuseColor = new BABYLON.Color3(0, 0, 0); // 薄い青色
          leftEllipseblack.material = ellipseMaterialblack;
          rightEllipseblack.material = ellipseMaterialblack;

          // 楕円形の追加装飾をグループ化
          const ellipseGroup = new BABYLON.TransformNode("ellipseGroup", scene);
          ellipseGroup.parent = cameraGroup;

          // 左目の楕円
          const leftEllipse = BABYLON.MeshBuilder.CreateSphere("leftEllipse", {diameterX: 10, diameterY: 16, diameterZ:1}, scene);
          leftEllipse.scaling.x = 5;
          leftEllipse.scaling.y = 2;
          leftEllipse.scaling.z = 0.5;
          leftEllipse.position.x = -40;
          leftEllipse.position.y = 40;
          leftEllipse.position.z = 0;
          leftEllipse.parent = ellipseGroup;

          // 右目の楕円
          const rightEllipse = BABYLON.MeshBuilder.CreateSphere("rightEllipse", {diameterX: 10, diameterY: 16, diameterZ:1}, scene);
          rightEllipse.scaling.x = 5;
          rightEllipse.scaling.y = 2;
          rightEllipse.scaling.z = 0.5;
          rightEllipse.position.x = 40;
          rightEllipse.position.y = 40;
          rightEllipse.position.z = 0;
          rightEllipse.parent = ellipseGroup;

          // 左目をグループ化
          const leftEyeGroup = new BABYLON.TransformNode("leftEyeGroup", scene);
          leftEyeGroup.parent = cameraGroup;

          // 左目
          const leftEye = BABYLON.MeshBuilder.CreateDisc("leftEye", {radius: 5}, scene);
          leftEye.position.x = -40;
          leftEye.position.y = 40;
          leftEye.position.z = -1;
          leftEye.parent = leftEyeGroup;

          // 左目の瞳
          const leftPupil = BABYLON.MeshBuilder.CreateDisc("leftPupil", {radius: 15}, scene);
          leftPupil.position.x = -40;
          leftPupil.position.y = 40;
          leftPupil.position.z = -0.5;
          leftPupil.parent = leftEyeGroup;

          // 右目をグループ化
          const rightEyeGroup = new BABYLON.TransformNode("rightEyeGroup", scene);
          rightEyeGroup.parent = cameraGroup;

          // 右目
          const rightEye = BABYLON.MeshBuilder.CreateDisc("rightEye", {radius: 5}, scene);
          rightEye.position.x = 40;
          rightEye.position.y = 40;
          rightEye.position.z = -1;
          rightEye.parent = rightEyeGroup;

          // 右目の瞳
          const rightPupil = BABYLON.MeshBuilder.CreateDisc("rightPupil", {radius: 15}, scene);
          rightPupil.position.x = 40;
          rightPupil.position.y = 40;
          rightPupil.position.z = -0.5;
          rightPupil.parent = rightEyeGroup;

          // 目の材質を設定
          const eyeMaterial = new BABYLON.StandardMaterial("eyeMaterial", scene);
          eyeMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1); // 白い眼球
          leftEyeGroup.material = eyeMaterial;
          rightEye.material = eyeMaterial;

          // 瞳の材質を設定
          const pupilMaterial = new BABYLON.StandardMaterial("pupilMaterial", scene);
          pupilMaterial.diffuseColor = new BABYLON.Color3(0, 0, 0); // 黒い瞳
          leftPupil.material = pupilMaterial;
          rightPupil.material = pupilMaterial;

          // 楕円の材質を設定
          const ellipseMaterial = new BABYLON.StandardMaterial("ellipseMaterial", scene);
          ellipseMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1); // 薄い青色
          leftEllipse.material = ellipseMaterial;
          rightEllipse.material = ellipseMaterial;

          cameraGroup.position.z = 499;

アニメーションは以下の処理を入れております。

          // マウスの動きで目を動かす
          canvas.addEventListener('mousemove', (event) => {
            const rect = canvas.getBoundingClientRect();
            const mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1; // -1 to 1
            const mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1; // -1 to 1

            // 目の動きの範囲を制限
            const eyeMovementRangeX = 6;
            const eyeMovementRangeY = 2;            

            const eyeOffsetX = mouseX * eyeMovementRangeX;
            const eyeOffsetY = mouseY * eyeMovementRangeY;

            // 左目グループの位置を更新
            leftEyeGroup.position.x = 0 + eyeOffsetX;
            leftEyeGroup.position.y = 0 + eyeOffsetY;

            // 右目グループの位置を更新
            rightEyeGroup.position.x = 0 + eyeOffsetX;
            rightEyeGroup.position.y = 0 + eyeOffsetY;


          });

ポスターが徐々に拡大していく仕掛け

時間経過に応じて、じわじわとポスターのサイズが大きくなります。
「気づかないうちに少しずつ異変が進行している」系の演出。

4.gif

Babylon.js の scaling を使い、
animation で一定速度で拡大していくだけのシンプルな構造ですが、
視覚的にかなり “不穏さ” が出ます。

          // ポスター
          const postersGroup = new BABYLON.TransformNode("postersGroup", scene);
          const posters_array = Array.from({length: 3}, (_, i) => `poster${i + 1}.png`);
          posters_array.map((poster, index) => {
            createposter(poster, index);
          });

          postersGroup.rotation.y = -Math.PI / 2;

          function createposter( poster, index ) {
            const posterMat = new BABYLON.StandardMaterial("posterMat");
            posterMat.diffuseTexture = new BABYLON.Texture(`textures/${poster}`, scene);

            const posterMesh = BABYLON.MeshBuilder.CreateBox("box", {height: 79.8, width: 70.0, depth: 0.1}, scene);
            posterMesh.material = posterMat;
            posterMesh.position.y = 0;
            posterMesh.position.x = (index * 250) - 250;
            posterMesh.position.z = 499;
            posterMesh.parent = postersGroup;

            // 初期スケールを1に設定
            posterMesh.scaling = new BABYLON.Vector3(1, 1, 1);
            const scaleAnimation = new BABYLON.Animation("scaleAnimation", "scaling", 30, BABYLON.Animation.ANIMATIONTYPE_VECTOR3, BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT);

            const keys = [];
            keys.push({frame: 0, value: new BABYLON.Vector3(1, 1, 1)});
            keys.push({frame: 600, value: new BABYLON.Vector3(3, 3, 3)}); // 10秒 = 300フレーム (30fps)

            scaleAnimation.setKeys(keys);
            posterMesh.animations.push(scaleAnimation);

            // アニメーションを開始
            scene.beginAnimation(posterMesh, 0, 600, false);
           }

禁煙ポスター:クリックで消える

禁煙ポスターをクリックすると、
シュッとフェードアウトして消える仕掛けも入れました。

2_1.gif

           // 禁煙ポスター
           const nosmokingGroup = new BABYLON.TransformNode("postersGroup", scene);
           nosmokingGroup.position.x = 499;
           nosmokingGroup.rotation.y = -Math.PI / 2;

           const nosmoking_array = Array.from({length: 20}, (_, i) => i + 1);
           nosmoking_array.forEach(function(element) {
            const nosmokingMat = new BABYLON.StandardMaterial("nosmokingMat");
            nosmokingMat.diffuseTexture = new BABYLON.Texture(`textures/nosmoking.png`, scene);

            const nosmokingMesh = BABYLON.MeshBuilder.CreateBox("box", {height: 153.6, width: 102.4, depth: 0.05}, scene);
            nosmokingMesh.material = nosmokingMat;
            const angle = (element / 10) * Math.PI * 2; // 0から2πまで
            const radius = Math.random() * 100 + 100; // ランダムな半径(50-150)
            nosmokingMesh.position.y = Math.sin(angle) * radius;
            nosmokingMesh.position.x = Math.cos(angle) * radius;
            nosmokingMesh.position.z = element * 0.1;

            nosmokingMesh.rotation.z = Math.sin(angle) * radius;
            nosmokingMesh.parent = nosmokingGroup;

            // クリックで消せるようにする
            nosmokingMesh.actionManager = new BABYLON.ActionManager(scene);
            nosmokingMesh.actionManager.registerAction(new BABYLON.ExecuteCodeAction(BABYLON.ActionManager.OnPickTrigger, function () {
                nosmokingMesh.dispose();
            }));

           });

防犯カメラの光が追従

防犯ポスターの目とほぼ同じ仕組みで、
カメラのライト部分がカーソルを追従して動きます。

3.gif

          // 防犯カメラ
          const crimeprevention = BABYLON.MeshBuilder.CreateSphere("sphere", {arc: 0.5, diameter: 50, sideOrientation: BABYLON.Mesh.DOUBLESIDE});
          crimeprevention.position.y = 500;
          crimeprevention.rotation.x = -Math.PI / 2;

          const crimepreventionMaterial = new BABYLON.StandardMaterial("crimepreventionMaterial", scene);
          crimepreventionMaterial.diffuseColor = new BABYLON.Color3(0.1, 0.1, 0.1);  // 少し明るい灰色
          crimepreventionMaterial.specularColor = new BABYLON.Color3(1, 1, 1);       // 白い光沢
          crimepreventionMaterial.specularPower = 10;                                // 光沢の強度
          crimepreventionMaterial.emissiveColor = new BABYLON.Color3(0, 0, 0); // 強い発光効果
          crimepreventionMaterial.alpha = .5; 
          crimeprevention.material = crimepreventionMaterial; 

          const crimepreventionlignt = BABYLON.MeshBuilder.CreateSphere("sphere", {arc: 1, diameter: 5, sideOrientation: BABYLON.Mesh.DOUBLESIDE});
          crimepreventionlignt.position.y = 477;

          const crimepreventionligntMaterial = new BABYLON.StandardMaterial("crimepreventionligntMaterial", scene);
          crimepreventionligntMaterial.diffuseColor = new BABYLON.Color3(1, .5, .5);  // 赤色
          crimepreventionligntMaterial.specularColor = new BABYLON.Color3(1, .5, .5);       // 白い光沢
          crimepreventionligntMaterial.specularPower = 100;                                // 光沢の強度
          crimepreventionligntMaterial.emissiveColor = new BABYLON.Color3(1, 0, 0); // 純粋な赤い発光効果
          crimepreventionlignt.material = crimepreventionligntMaterial;

アニメーションは以下の処理を入れています。

      // マウスの動きで目を動かす
      canvas.addEventListener('mousemove', (event) => {
        const rect = canvas.getBoundingClientRect();
        const mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1; // -1 to 1
        const mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1; // -1 to 1            

        // 防犯カメラの位置を更新
        crimepreventionlignt.position.x = mouseX * 3;
        crimepreventionlignt.position.z = - mouseY * 3;
      });

8番出口イベントの思い出

そういえば今年の8月、東京メトロで開催されていた
リアル“8番出口”謎解きイベントに参加してきました!

最初は簡単だったけど、後半は難易度がぐっと上がり、
参加者みんなで協力しながらじゃないとクリアできない感じで、
気づけば 5時間歩きっぱなし。
めちゃくちゃ大変でしたが、すごく楽しかったです!

GzmSGhHbUAAE0hJ.jpg

さいごに

AIの導入や業務改善も大事ですが、「作りたいものを純粋に作る時間」もやっぱり技術者にとっての大事ですよね!

良いお年をお迎えください!
来年もいろいろ作っていきたいと思います!

リンク先URL

プルリクエスト
https://github.com/uemura5683/babylonjs/tree/main/the_exit_8

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?