Home Reference Source

src/components/structural/View.js

import React, { Component, Fragment } from "react";
import { browserType } from "../../utils/browserType";
import "aframe";
import "three-pathfinding/dist/three-pathfinding";
import "aframe-extras/dist/aframe-extras.min.js";
import "@engaging-computing/aframe-physics-system";
import "aframe-environment-component";

/**
 * The View component return the aframe representation of the scene. This
 * system utilizes the entity component system(ECS) to build objects in the scene from different
 * components.
 */
class View extends Component {
    constructor(props) {
        super(props);
        this.state = {
            welcomeOpen: true
        };
    }
    /**
     * The timer ID set to check if the welcome screen is open
     * It will use it to cancel the timer once the welcome screen is close
     */
    intervalID = 0;

    /**
     * Called when the MYR Scene is mounted (component has been rendererd to the DOM)
     * 
     * It check if the user's first time visiting, then it will set up a interval to see if the welcome screen is closed for every 1000ms
     * It also add a event listener to 
     *     stop key press of movement (arrow key and space) to scroll the page
     *     hide or show "interface" (editor) depends on when user enter or exit the VR(fullscreen) mode.
     */
    componentDidMount() { 
        if (!this.getCookie("hasVisited")) {
            this.intervalID = setInterval(this.checkForWelcomeScreen, 1000);
        }
        else {
            this.setState({ welcomeOpen: false });
        }
        window.addEventListener("keydown", function (e) {
            //KEYS: left and right: 37, 39; up and down: 38, 40; space: 32
            if ([32, 38, 40].indexOf(e.keyCode) > -1 && e.target === document.body) {
                e.preventDefault();
            }
        }, false);

        window.addEventListener("enter-vr", () => {
            document.getElementById("interface").style.visibility = "hidden";
        });

        window.addEventListener("exit-vr", () => {
            document.getElementById("interface").style.visibility = "visible";
        });
    }
    
    /**
     * Called when MYR Scene is rendererd (DOM elements is changed/updated)
     * 
     * If the welcome screen is closed, it fires an event for "myr-view-rendererd" which added in
     *  change() & push() in Myr.js but these are unused functions that are not documented in reference. 
     */
    componentDidUpdate() {
        if (!this.state.welcomeOpen) {
            // Create the event
            let event = new CustomEvent("myr-view-rendered");

            // Dispatch/Trigger/Fire the event
            document.dispatchEvent(event);
        }   
    }

    /**
     * Called when the MYR Scene is unmounting (Being removed from the DOM)
     * 
     * It will check if the timerID for checking welcome screen is set, 
     * If so it will clear/stop the timer from checking.
     */
    componentWillUnmount() {
        if (this.intervalID !== 0) { clearInterval(this.intervalID); }
    }

    /**
     * On every interval (1000ms) it check whether the cookie hasVisited is set to true
     * If so, set state "welcomeOpen" to false (so it will render the scene) 
     * and stop the timer. 
     */
    checkForWelcomeScreen = () => {
        if (this.getCookie("hasVisited")) {
            this.setState({ welcomeOpen: false });
            clearInterval(this.intervalID);
            this.intervalID = 0;
        }
    }

    /**
     * Get value of cookie
     * @param {string} cookieName name of cookie
     * @returns {string} value of cookie if it exist, return empty string otherwise
     */
    getCookie = (cookieName) => {
        let name = cookieName + "=";
        let decodedCookie = decodeURIComponent(document.cookie);
        let ca = decodedCookie.split(";");
        for (let i = 0; i < ca.length; i++) {
            let c = ca[i];
            while (c.charAt(0) === " ") {
                c = c.substring(1);
            }
            if (c.indexOf(name) === 0) {
                return c.substring(name.length, c.length);
            }
        }
        return "";
    }

    /**
     * A helper function that converts MYR objects into a corresponded A-Frame DOM elements
     * 
     * @param {object} ent MYR object
     * @returns {HTMLElement} A-Frame entities
     */
    helper = (ent) => {
        if (ent) {
            let flattened = {
                ...ent,
                position: `${ent.position.x || 0} ${ent.position.y || 0} ${ent.position.z || 0}`,
                scale: `${ent.scale.x || 1} ${ent.scale.y || 1} ${ent.scale.z || 1}`,
                rotation: `${ent.rotation.x || 0} ${ent.rotation.y || 0} ${ent.rotation.z || 0}`
            };
            // If it is group then render children then render parent
            if (ent.group) {
                return (
                    <a-entity key={ent.id} {...flattened}>
                        {ent.els ? ent.els.map(it => this.helper(it)) : null}
                    </a-entity>
                );
            }
            if(ent.light){
                delete flattened.light;
                let target=null,indicator=null;
                if(ent.light.target){ 
                    //Since the target will override the rotation, we want to delete the rotation attribute so it won't have an effect on the rotation of indicator.
                    delete flattened.rotation;
                    ent.light.state +="target: #lighttarget;";
                    let target_position= `${ent.light.target.x || 0} ${ent.light.target.y || 0} ${ent.light.target.z || 0}`;
                    target = <a-entity id="lighttarget"  position={target_position}></a-entity>;
                }
                if(this.props.sceneConfig.settings.lightIndicator){
                    indicator = this.lightIndicatorHelper(ent);
                }
                if(this.props.sceneConfig.settings.castShadow){
                    ent.light.state += this.lightShadowHelper(ent.light);
                }
                return [<a-entity key={ent.id} id={ent.id} light={ent.light.state} {...flattened}>{indicator}</a-entity>,target];
            }
            let shadow;
            if(this.props.sceneConfig.settings.castShadow){
                shadow = "cast:true; receive:true;";
            }else{
                shadow = "cast:false; receive:false;";
            }

            if (ent.text) {
                delete flattened.text; // this takes care of a warning, may not be necessary
                return <a-text key={ent.id} {...flattened}></a-text>;
            }
            if (ent.tube) {
                return <a-tube path={ent.path} radius={ent.radius} material={ent.material} shadow={shadow} shadowcustomsetting></a-tube>;
            }
            return <a-entity class="raycastable" key={ent.id} {...flattened} shadow={shadow} shadowcustomsetting ></a-entity>;
        }
    }
    /**
     * Helper fuctions that returns A-Frame elements that contains necessary configuration for light indicator based on light's type and properties
     * 
     * @param {object} ent Myr object
     * @returns A-Frame entities of light indicator
     */
    lightIndicatorHelper =(ent)=>{ 
        //this is a position for passing in to indicatorroation to determine the rotation of the light that use position as vector.
        let position =`position:${ent.position.x || 0} ${ent.position.y || 0} ${ent.position.z || 0};`;
        if(ent.light.target){
            position += `target:${ent.light.target.x || 0} ${ent.light.target.y || 0} ${ent.light.target.z || 0};`;
        } 

        //ambient light doesn't have an indicator
        switch(ent.light.type){
            case "point":
                return <a-entity id={ent.id+"Ind"} key={ent.id+"Ind"} pointlightindicator={`color: ${ent.color};`}></a-entity>;
            case "spot":
                let target = true;
                if(!ent.light.target) {
                    position = "";
                    target = false;
                }
                return <a-entity id={ent.id+"Ind"} key={ent.id+"Ind"} spotlightindicator={`color: ${ent.color}; target:${target}`} indicatorrotation={position}></a-entity>;
            case "directional":
                return <a-entity id={ent.id+"Ind"} key={ent.id+"Ind"} directionallightindicator={`color: ${ent.color};`} indicatorrotation={position}></a-entity>;
            case "hemisphere":
                return <a-entity id={ent.id+"Ind"} key={ent.id+"Ind"} hemispherelightindicator={`color: ${ent.color}; secondColor: ${ent.light.secondColor}`} ></a-entity>;
            default:
        }
    }

    /**
     * Helper function that returns configuration for shadow based on light's type
     * 
     * @param {object} light light properties
     * @returns {string} string of aframe configuration of shadow that will attach to the light attribute
     */
    lightShadowHelper = (light) =>{
        let newState = "";
        //ambient and hemisphere light doesn't cast shadow
        if(light.type !== "ambient" && light.type !== "hemisphere"){
            newState += "castShadow:true; shadowMapHeight:2000; shadowMapWidth:2000;";
            if(light.type === "spot"){
                newState += "shadowBias: -0.02; shadowCameraNear: 7;";
            }else if(light.type ==="directional"){
                newState += "shadowCameraNear: -40; shadowBias: -0.002; shadowCameraTop: 40; shadowCameraBottom: -40; shadowCameraLeft: -40; shadowCameraRight: 40;";
            }else if(light.type === "point"){
                newState += "shadowCameraFar: 25; shadowBias: -0.02;";
            }
        }else{
            newState += "castShadow: false;";
        }
        return newState;
    }

    /**
     * Help convert asset(model,image,etc) datas to A-Frame entitiy
     * 
     * @param {object} asset 
     * @returns {HTMLElement} a-asset-item of the object
     */
    assetsHelper = (asset) => {
        return (
            <a-asset-item key={asset.id} id={asset.id} src={asset.src}></a-asset-item>
        );
    }

    /**
     * It creates 2 different A-Frame camera. As for now, it will always be basicMoveCam.
     * 
     * @returns {HTMLElement} Camera elements
     */
    createCam = () => {
        switch (this.props.sceneConfig.settings.camConfig) {
            case 0:
                return this.basicMoveCam();
            case 1:
                return this.checkpointCam();
            default:
                return this.basicMoveCam();
        }
    }

    /**
     * Check point cam is a mode where there's a checkpoints in the scene where user can select them to teleport to that position 
     * to move around the scene compare to the regular movement.
     * 
     * Check point is currently not implemented in MYR to be an usable feature.
     * 
     * @returns {HTMLElement} Camera elements contains checkpoint control
     */
    checkpointCam = () => {
        return (
            <a-entity id="rig" movement-controls="controls: checkpoint" checkpoint-controls="mode: animate">
                <a-camera
                    position={this.props.sceneConfig.settings.cameraPosition}
                    look-controls="pointerLockEnabled: true">
                    <a-cursor
                        position="0 0 -1"
                        geometry="primitive: ring; radiusInner: 0.02; radiusOuter: 0.03;"
                        material="color: #CCC; shader: flat;" />
                </a-camera>
            </a-entity>
        );
    }

    /**
     * 
     * toggles cursor depending on the settings by changing opacity
     * 
     * @returns {string} String of aframe configuration of cursor attributes
     */
    displayCursor = () => {
        if (this.props.sceneConfig.settings.showCursor) {
            return "color: #CCC; shader: flat; opacity: 1;";
        }
        else 
        {
            return "color: #CCC; shader: flat; opacity: 0;";
        }
    }

    /**
     * It returns camera basic with different movement control depends on the browser type
     *      Mobile:
     *          default movement controls with flying enabled
     *      VR:
     *          default movement controls with flying disabled
     *      Desktop:
     *          custom wasd-plus-control with controllable movement speed
     * 
     * @returns {HTMLElement} A-Frame camera elements with basic movement
     */
    basicMoveCam = () => {
        let realVertSpeed = (this.props.sceneConfig.settings.moveSpeed / 2) + 10;
        let realHorizontalSpeed = (this.props.sceneConfig.settings.moveSpeed / 10) + 10;
        switch(browserType()) {
            case "mobile":
                return (
                    <a-entity id="rig" 
                        debug={true}
                        movement-controls="fly: true">
                        <a-camera
                            position={this.props.sceneConfig.settings.cameraPosition}
                            look-controls="pointerLockEnabled: true">
                            <a-cursor
                                raycaster="objects:.raycastable"
                                position="0 0 -1"
                                geometry="primitive: ring; radiusInner: 0.02; radiusOuter: 0.03;"
                                material={this.displayCursor()} />
                        </a-camera>
                    </a-entity> 
                );
            case "vr":
                return (
                    <a-entity id="rig" 
                        debug={true}
                        movement-controls>
                        <a-camera
                            position={this.props.sceneConfig.settings.cameraPosition}>
                            <a-cursor
                                raycaster="objects:.raycastable"
                                position="0 0 -1"
                                geometry="primitive: ring; radiusInner: 0.02; radiusOuter: 0.03;"
                                material={this.displayCursor()} />
                        </a-camera>
                    </a-entity> 
                );
            case "desktop":
            default:
                return (
                    <a-entity id="rig" debug={true}>
                        <a-camera
                            position={this.props.sceneConfig.settings.cameraPosition}
                            look-controls="pointerLockEnabled: true"
                            wasd-plus-controls={`acceleration: ${realVertSpeed}`}
                            wasd-controls={`acceleration: ${realHorizontalSpeed}`}>
                            <a-cursor
                                raycaster="objects:.raycastable"
                                position="0 0 -1"
                                geometry="primitive: ring; radiusInner: 0.02; radiusOuter: 0.03;"
                                material={this.displayCursor()} />
                        </a-camera>
                    </a-entity>
                );
        }
    }



    /**
    * Produces the grid on the ground and the coordinate lines
    * 
    * @returns {HTMLElements} A-Frame entitiy containing grid, tube for axies and text to label those axes 
    */
    coordinateHelper = () => {
        if (this.props.sceneConfig.settings.showCoordHelper) {
            return (
                <Fragment>
                    <a-grid height="53.33" width="53.33" position="-0.5 -0.26 -0.5" scale="1.5 1.5 1.5" material="shader:flat;" gridmaterial/>
                    <a-tube path="-35 -0.2 0, 35 -0.2 0" radius="0.05" material="color: red; shader:flat;"></a-tube>
                    <a-tube path="0 -0.2 -35, 0 -0.2 35" radius="0.05" material="color: blue; shader:flat;"></a-tube>
                    <a-tube path="0 -35 0, 0 35 0" radius="0.05" material="color: green; shader:flat;"></a-tube>
                    <a-text
                        color="#555"
                        rotation="0 0 0"
                        position="-0.0005 .1 0"
                        side="double"
                        align="center"
                        value="- X           X +"></a-text>
                    <a-text
                        color="#555"
                        rotation="0 90 0"
                        position="0 .1 -0.01"
                        side="double"
                        align="center"
                        value="+ Z          Z -"></a-text>
                    <a-text
                        color="#555"
                        rotation="0 90 90"
                        position=".1 .1 0"
                        side="double"
                        value=" Y + "></a-text>
                </Fragment>
            );
        } else {
            return null;
        }
    }

    /**
     * Display the floor if the showFloor setting is true 
     * @returns 80x80 plane with showFloor color or null if the showFloor is false
     */
    makeFloor = () => {
        if (this.props.sceneConfig.settings.showFloor) {
            return (
                <a-plane
                    id="floor"
                    geometry="primitive: box;"
                    color={this.props.sceneConfig.settings.floorColor}
                    material="side: double;"
                    static-body="shape: box"
                    scale="80 .01 80"
                    position="0 -0.5 0"
                    shadow={`cast: false; receive: ${this.props.sceneConfig.settings.castShadow}`}
                />
            );
        } else {
            return null;
        }
    }

    /**
     * Main part for Rendering MYR Scene!
     * 
     * @returns {HTMLElement} A-Frame scene containing all the settings and MYR objects
     */
    render = () => {
        /* eslint-disable */
        return (
            !this.state.welcomeOpen ?
            <a-scene shadow="type:pcf;" physics="debug: false; friction: 3; restitution: .3;" embedded debug="false">
                <a-assets>
                    <a-mixin id="checkpoint"></a-mixin>
                    <a-mixin id="checkpoint-hovered" color="#6CEEB5"></a-mixin>
                    <a-img id="reference" src={`${process.env.PUBLIC_URL}/img/coordHelper.jpg`} />
                    {this.props.assets ? this.props.assets.map((x) => this.assetsHelper(x)) : null}
                </a-assets>
                <a-sky color={this.props.sceneConfig.settings.skyColor} />
                {this.coordinateHelper()}
                {this.makeFloor()}
                {this.props.sceneConfig.settings.defaultLight ? 
                            <a-entity id="DefaultLight">                   
                                <a-entity id="AmbientLight" light="type: ambient; color: #BBB"></a-entity>
                                <a-entity id="DirectionalLight" light={"type: directional; color: #FFF; intensity: 0.6; " + this.lightShadowHelper({state: "",type: "directional"})} position="-3 3 1"></a-entity> 
                            </a-entity>  
                    : null
                }
                { // create the entities
                    Object.keys(this.props.objects).map(it => {
                        return this.helper(this.props.objects[it]);
                    })
                }
                {this.createCam()}
                {this.props.sceneConfig.settings.camConfig === 1 ?
                    <a-entity position="0 0 0">
                        <a-cylinder checkpoint radius="1" height="0.3" position="-25 1 -25" color="#39BB82"></a-cylinder>
                        <a-cylinder checkpoint radius="1" height="0.3" position="25 1 25" color="#39BB82"></a-cylinder>
                        <a-cylinder checkpoint radius="1" height="0.3" position="-25 1 25" color="#39BB82"></a-cylinder>
                        <a-cylinder checkpoint radius="1" height="0.3" position="25 1 -25" color="#39BB82"></a-cylinder>
                        <a-circle checkpoint radius="1" rotation="90 0 0" position="0 10 0" color="#39BB82"></a-circle>
                    </a-entity>
                    : null
                }
            </a-scene>
                :
                null
        );
        /* eslint-enable */
    }
}

export default View;