PlayCanvasでインタラクティブなスライディングドアを実装する方法

PlayCanvasでインタラクティブなスライディングドアを実装する方法

こんにちは、チャリセです!
今回は、Playcanvas上でプレイヤーが近づくと自動的に開き、離れると閉じるスライディングドアの実装方法について解説します。
PlayCanvasは、WebGLベースの強力なゲームエンジンです。Javascriptを一から勉強しながらWebGLアプリやゲーム開発したい方におすすめです!では、早速解説していきます。

このスクリプトで実装できる機能は以下の通りです:
・ 単一のドアまたは両開きドアに対応
・ 水平方向または垂直方向のスライド
・ カスタマイズ可能なアニメーション距離と時間
・ 11種類のイージング関数による滑らかな動き
・ プレイヤー検知用のトリガーエリア

var SlidingDoorController = pc.createScript('slidingDoorController');

SlidingDoorController.attributes.add('slideDistance', {
    type: 'number',
    default: 2,
    title: 'Slide Distance'
});

SlidingDoorController.attributes.add('slideDirection', {
    type: 'string',
    enum: [
        { 'Left/Right': 'horizontal' },
        { 'Up/Down': 'vertical' }
    ],
    default: 'horizontal',
    title: 'Slide Direction'
});

SlidingDoorController.attributes.add('colliderDepth', {
    type: 'number',
    default: 1.5,
    title: 'Collider Depth'
});

SlidingDoorController.attributes.add('animationDuration', {
    type: 'number',
    default: 1,
    title: 'Animation Duration (seconds)'
});

SlidingDoorController.attributes.add('easingType', {
    type: 'string',
    enum: [
        { 'Linear': 'linear' },
        { 'Quadratic': 'quadratic' },
        { 'Cubic': 'cubic' },
        { 'Quartic': 'quartic' },
        { 'Quintic': 'quintic' },
        { 'Sinusoidal': 'sinusoidal' },
        { 'Exponential': 'exponential' },
        { 'Circular': 'circular' },
        { 'Elastic': 'elastic' },
        { 'Back': 'back' },
        { 'Bounce': 'bounce' }
    ],
    default: 'quadratic',
    title: 'Animation Easing'
});

SlidingDoorController.prototype.initialize = function() {
    this.isOpen = false;
    this.isAnimating = false;
    this.currentTime = 0;
    
    var frameData = this.getEntityDimensions(this.entity);
    
    // Find door entities and categorize them
    this.doors = [];
    let leftDoor = null;
    let rightDoor = null;
    
    this.entity.children.forEach(child => {
        if (child.name.toLowerCase().includes('door')) {
            if (child.name.toLowerCase().includes('left')) {
                leftDoor = child;
            } else if (child.name.toLowerCase().includes('right')) {
                rightDoor = child;
            }
        }
    });
    
    // Handle double doors
    if (leftDoor && rightDoor) {
        // Add left door first
        this.doors.push({
            entity: leftDoor,
            startPosition: new pc.Vec3(),
            currentPosition: new pc.Vec3(),
            targetPosition: new pc.Vec3(),
            closedPosition: leftDoor.getLocalPosition().clone(),
            openPosition: this.calculateOpenPosition(leftDoor, 'left')
        });
        
        // Add right door second
        this.doors.push({
            entity: rightDoor,
            startPosition: new pc.Vec3(),
            currentPosition: new pc.Vec3(),
            targetPosition: new pc.Vec3(),
            closedPosition: rightDoor.getLocalPosition().clone(),
            openPosition: this.calculateOpenPosition(rightDoor, 'right')
        });
    } else {
        // Handle single door case
        const singleDoor = leftDoor || rightDoor || this.entity.children.find(child => 
            child.name.toLowerCase().includes('door')
        );
        
        if (singleDoor) {
            this.doors.push({
                entity: singleDoor,
                startPosition: new pc.Vec3(),
                currentPosition: new pc.Vec3(),
                targetPosition: new pc.Vec3(),
                closedPosition: singleDoor.getLocalPosition().clone(),
                openPosition: this.calculateOpenPosition(singleDoor, 'right')
            });
        }
    }
    
    this.triggerEntity = new pc.Entity('DoorTrigger');
    this.entity.addChild(this.triggerEntity);
    
    this.triggerEntity.setLocalPosition(0, frameData.center.y, 0);
    
    this.triggerEntity.addComponent('collision', {
        type: 'box',
        halfExtents: new pc.Vec3(
            frameData.size.x / 2,
            frameData.size.y / 2,
            this.colliderDepth
        ),
        trigger: true
    });
    
    this.createCollisionVisual();
    this.triggerEntity.tags.add('ignoreCamera');
    
    this.triggerEntity.collision.on('triggerenter', this.onPlayerEnter, this);
    this.triggerEntity.collision.on('triggerleave', this.onPlayerLeave, this);
};

SlidingDoorController.prototype.calculateOpenPosition = function(door, direction) {
    var openPos = door.getLocalPosition().clone();
    var offset = this.slideDistance;
    
    // Set direction based on door type
    if (direction === 'left') {
        offset *= -1;
    }
    
    if (this.slideDirection === 'horizontal') {
        openPos.x += offset;
    } else {
        openPos.y += offset;
    }
    
    return openPos;
};

SlidingDoorController.prototype.easing = {
    linear: function(t) { return t; },
    quadratic: function(t) {
        return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
    },
    cubic: function(t) {
        return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
    },
    quartic: function(t) {
        return t < 0.5 
            ? 8 * t * t * t * t 
            : 1 - Math.pow(-2 * t + 2, 4) / 2;
    },
    quintic: function(t) {
        return t < 0.5 
            ? 16 * t * t * t * t * t 
            : 1 - Math.pow(-2 * t + 2, 5) / 2;
    },
    sinusoidal: function(t) {
        return -(Math.cos(Math.PI * t) - 1) / 2;
    },
    exponential: function(t) {
        return t === 0
            ? 0
            : t === 1
            ? 1
            : t < 0.5 
            ? Math.pow(2, 20 * t - 10) / 2
            : (2 - Math.pow(2, -20 * t + 10)) / 2;
    },
    circular: function(t) {
        return t < 0.5
            ? (1 - Math.sqrt(1 - Math.pow(2 * t, 2))) / 2
            : (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2;
    },
    elastic: function(t) {
        const c5 = (2 * Math.PI) / 4.5;
        return t === 0 ? 0 
            : t === 1 ? 1 
            : t < 0.5
            ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2
            : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + 1;
    },
    back: function(t) {
        const c1 = 1.70158;
        const c2 = c1 * 1.525;
        return t < 0.5
            ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
            : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
    },
    bounce: function(t) {
        const n1 = 7.5625;
        const d1 = 2.75;
        
        const bounce = function(t) {
            if (t < 1 / d1) {
                return n1 * t * t;
            } else if (t < 2 / d1) {
                return n1 * (t -= 1.5 / d1) * t + 0.75;
            } else if (t < 2.5 / d1) {
                return n1 * (t -= 2.25 / d1) * t + 0.9375;
            } else {
                return n1 * (t -= 2.625 / d1) * t + 0.984375;
            }
        };
        
        return t < 0.5
            ? (1 - bounce(1 - 2 * t)) / 2
            : (1 + bounce(2 * t - 1)) / 2;
    }
};

SlidingDoorController.prototype.getEntityDimensions = function(entity) {
    if (entity.model) {
        var mesh = entity.model.model.meshInstances[0];
        var aabb = mesh.aabb;
        
        var scale = entity.getLocalScale();
        var size = new pc.Vec3();
        size.copy(aabb.halfExtents).mul(scale).scale(2);
        
        var center = new pc.Vec3();
        center.copy(aabb.center).mul(scale);
        
        return {
            size: size,
            center: center
        };
    }
    
    return {
        size: new pc.Vec3(2, 3, 0.5),
        center: new pc.Vec3(0, 1.5, 0)
    };
};

SlidingDoorController.prototype.createCollisionVisual = function() {
    this.triggerEntity.addComponent('model', {
        type: 'box',
        castShadows: false
    });
    
    var material = new pc.StandardMaterial();
    material.diffuse.set(0, 0, 1);
    material.opacity = 0.3;
    material.blendType = pc.BLEND_NORMAL;
    material.update();
    
    this.triggerEntity.model.model.meshInstances[0].material = material;
    
    var collisionExtents = this.triggerEntity.collision.halfExtents;
    this.triggerEntity.setLocalScale(
        collisionExtents.x * 2,
        collisionExtents.y * 2,
        collisionExtents.z * 2
    );
};

SlidingDoorController.prototype.onPlayerEnter = function(entity) {
    if (entity.tags && entity.tags.has('player')) {
        this.doors.forEach(door => {
            door.startPosition.copy(door.entity.getLocalPosition());
            door.targetPosition.copy(door.openPosition);
        });
        this.currentTime = 0;
        this.isAnimating = true;
        this.isOpen = true;
    }
};

SlidingDoorController.prototype.onPlayerLeave = function(entity) {
    if (entity.tags && entity.tags.has('player')) {
        this.doors.forEach(door => {
            door.startPosition.copy(door.entity.getLocalPosition());
            door.targetPosition.copy(door.closedPosition);
        });
        this.currentTime = 0;
        this.isAnimating = true;
        this.isOpen = false;
    }
};

SlidingDoorController.prototype.update = function(dt) {
    if (this.isAnimating) {
        this.currentTime += dt;
        var t = Math.min(this.currentTime / this.animationDuration, 1);
        
        // Apply easing
        var easedT = this.easing[this.easingType](t);
        
        this.doors.forEach(door => {
            door.currentPosition.lerp(door.startPosition, door.targetPosition, easedT);
            door.entity.setLocalPosition(door.currentPosition);
        });
        
        if (t >= 1) {
            this.isAnimating = false;
        }
    }
};

コードの解説

1. スクリプトの初期化と属性設定
スクリプトの冒頭では、PlayCanvasエディタから設定可能な属性を定義しています:
➤ slideDistance: ドアが開くときの移動距離
➤ slideDirection: 開く方向(水平/垂直)
➤ colliderDepth: プレイヤー検知用コライダーの深さ
➤ animationDuration: アニメーション時間
➤ easingType: アニメーションのイージングタイプ

2. 初期化処理(initialize)
initialize関数では、以下の重要な設定を行います:
➤ ドアエンティティの検索と設定
➤ トリガーエリアの作成と配置
➤ 開閉位置の計算と保存

3. イージング関数
11種類のイージング関数が実装されています:
● Linear(線形)
● Quadratic(二次関数)
● Cubic(三次関数)
● Quartic(四次関数)
● Quintic(五次関数)
● Sinusoidal(正弦波)
● Exponential(指数関数)
● Circular(円形)
● Elastic(弾性)
● Back(バック)
● Bounce(バウンド)

4. エンティティの寸法取得
getEntityDimensions関数は、ドアエンティティのサイズと中心位置を計算します。これはトリガーエリアの配置に使用されます。

5. コライダーの可視化
createCollisionVisual関数では、デバッグ用にトリガーエリアを可視化します。半透明の青いボックスとして表示されます。

6. プレイヤーの検知と動作制御
プレイヤーの検知と動作は以下の関数で制御されています:
■ onPlayerEnter

SlidingDoorController.prototype.onPlayerEnter = function(entity) {
    if (entity.tags && entity.tags.has('player')) {
        this.doors.forEach(door => {
            door.startPosition.copy(door.entity.getLocalPosition());
            door.targetPosition.copy(door.openPosition);
        });
        this.currentTime = 0;
        this.isAnimating = true;
        this.isOpen = true;
    }
};

この関数は以下の動作を行います:
プレイヤータグを持つエンティティのみに反応
・各ドアの現在位置を開始位置として保存
・目標位置を開放位置に設定
・アニメーションをトリガー

■ onPlayerLeave

SlidingDoorController.prototype.onPlayerLeave = function(entity) {
    if (entity.tags && entity.tags.has('player')) {
        this.doors.forEach(door => {
            door.startPosition.copy(door.entity.getLocalPosition());
            door.targetPosition.copy(door.closedPosition);
        });
        this.currentTime = 0;
        this.isAnimating = true;
        this.isOpen = false;
    }
};

この関数では:
・プレイヤーが範囲外に出たときの処理
・ドアを閉じる位置への移動を開始
・アニメーション状態のリセット

7. アニメーションの更新処理
update関数では、毎フレームのアニメーション更新を行います:

SlidingDoorController.prototype.update = function(dt) {
    if (this.isAnimating) {
        this.currentTime += dt;
        var t = Math.min(this.currentTime / this.animationDuration, 1);
        
        // イージングの適用
        var easedT = this.easing[this.easingType](t);
        
        this.doors.forEach(door => {
            door.currentPosition.lerp(door.startPosition, door.targetPosition, easedT);
            door.entity.setLocalPosition(door.currentPosition);
        });
        
        if (t >= 1) {
            this.isAnimating = false;
        }
    }
};

このアニメーション処理の特徴:
・デルタタイムを使用した時間ベースのアニメーション
・選択されたイージング関数の適用
・線形補間(lerp)による滑らかな移動
・アニメーション完了の適切な処理

PlayCanvasを使用したインタラクティブなスライディングドアの実装について、詳しく解説しました。よかったら是非こちらのコードをお試しください。
HappyCoding!!

現在
株式会社チョモランマ
株式会社シェルパ
3Dmodeljapan株式会社
ではスタッフを大募集しております!!
Unreal Engine4、AI、プログラミングや建築パースに興味がある方!
ぜひご応募下さい!!
初心者の方、未経験の方やインターンを受けてみたい方々でも大歓迎です!!