[ShowCase] My DX to make Playcanvas Game

Hello! I’m happy to show my game advances since previous updates! I almost finish my 5 minigame in my game the goal is to have 47 :smiley::.

U can see a demo of it working here:

Demo Video

The Game Demo

Notice, when access the game Demo u will be in my university campus, so u must walk and talk with the NPC to access the minigame.

My Developer Experience with Playcanvas

I’m a web developer for 1-2 years, and i’m working with playcanvas for 5 months, i felt like i growth a lot! The Playcanvas api docs is just amazing when u start to read and understend it more!

I have not experience with anoter engines or tools, but i fell like playcanvas is just complete. Has some issues. Like boring problems with script creations needing reload to show , or the AmmoJs as a not too good physics engine.or something like that. But these problems are just details.

The Project

The project started 5 months ago, and is being updated every day since then.

I really think this is a BIG Playcanvas Project, in the current state has a lot of scripts and things that i would be happy to help and teach people to implement in their own games, like Inventory System. Game Saves MiniGames, Conquest System, Dialog. And so on.

I’m from Brazil. And the game is specific for my university so all the texts obviously is in PT-BR, but would not be a great problem to make it translatable for another languages like english with the project structure.

My dificulties.

So i will make this post like a kind of Devlog, so let’s move on and see some code!

In the first moment is good to talk about what is this minigame? This minigame is a Scape Room in the Wright course, so the player must enter the room collect evidences in 5 minutes (in the demo i reduced to 1) and use it in the tribunal.

Has some cool features like locked door, item management and so on!

But let’s talk about code now!

The major problem of making it is THE SAVE!! In my opinion is kind of dificult to decide what must be saved to next session or what must not, or what is session specific so in the next session should not being “remembed”, i’ll describe it better now!

Like the player inventory or position or the conquests this player has, it is to be saved for every user session and it’s simple and easy to see.

BUT, things like the minigame state (the items he collected) doors he unlocked, at the first time i’ve think to not save it because is minigame specific, but it causes problems when the player moves to the starting scene. The thinking room where he can read the items descriptions to use it in the tribunal. Because when he moves back must unlock the doors unlocked it again, and repick the items, but the inventory is saved so it duplicate the items… And these problems:

And here is the sollution.

/**
 * Manages the state and rendering logic for items in the game.
 * This class handles both stateful and stateless items, determining
 * whether an item should be rendered and handling item collection.
 */
class ItemStateManager {
    /**
     * Retrieves the items for the current scene from the player's state.
     * It distinguishes between stateful and stateless items.
     * 
     * @returns {Object} An object containing `statefull` and `stateless` items.
     */
    static get sceneItems() {
        const sceneName = State.player.currentScene;
        State.player.items = State.player.items || {};
        return {
            statefull: State.player.items[sceneName] || {
            
            },

            stateless: window.game.items.stateless[sceneName] || {
                
            }
        };
    }

    /**
     * Determines whether a given item should be rendered.
     * It checks if the item is in the player's inventory or the current scene's items.
     * 
     * @param {string} guid - The unique identifier of the item.
     * @param {boolean} [isStateless=false] - Indicates if the item is stateless.
     * @returns {boolean} True if the item should be rendered, otherwise false.
     */
    static shouldRenderItem(
        guid, 
        isStateless = false
    ) {
        let item = undefined;
        
        if(!State.player.inventory.backgroundItems) return true;

        State.player.inventory.backgroundItems.forEach((line) => {
            line.forEach((_item) => {
                if(_item && _item.guid === guid) item = _item;
            });
        });

        if(item) return !item.isUnique;

        const manager = ItemStateManager.sceneItems;

        const itemManager = isStateless
            ? manager.stateless
            : manager.statefull;
        
        item = itemManager[guid];

        if(item) return !item.isUnique;

        return true;
    }

    /**
     * Collects an item and updates the appropriate item manager.
     * The item is added to the `statefull` or `stateless` item manager
     * based on its type.
     * 
     * @param {Object} item - The item object to be collected.
     * @param {string} item.guid - The unique identifier of the item.
     * @param {boolean} item.isStateless - Indicates if the item is stateless.
     * @param {boolean} item.isUnique - Indicates if the item is unique.
     */
    static collectItem(
        item,
    ){
        const manager = ItemStateManager.sceneItems;

        const itemManager = item.isStateless
            ? manager.stateless
            : manager.statefull;
        
        itemManager[item.guid] = item;
    }

    /**
     * Drops an item and updates the appropriate item manager.
     * The item is added to the `statefull` or `stateless` item manager
     * based on its type.
     * 
     * @param {Object} item - The item object to be collected.
     * @param {string} item.guid - The unique identifier of the item.
     * @param {boolean} item.isStateless - Indicates if the item is stateless.
     * @param {boolean} item.isUnique - Indicates if the item is unique.
     */
    static dropItem (
        item
    ){
        const manager = ItemStateManager.sceneItems;

        const itemManager = item.isStateless
            ? manager.stateless
            : manager.statefull;
        
        delete itemManager[item.guid];
    }

    /**
     * Configures the initial item state for the game, setting up the
     * player's items and the global stateless items for the current scene.
     * 
     * @param {Object} app - The application instance (or other context object).
     */
    static configure(app){
        window.game = window.game || {};
        window.game.items = window.game.items || {};
        window.game.items.stateless = window.game.items.stateless || {};

        const sceneName = State.player.currentScene;
        const items = ItemStateManager.sceneItems;

        State.player.items = State.player.items || {};
        State.player.items[sceneName] = items.statefull;

        window.game.items.stateless[sceneName] = items.stateless;
    }
}

// Add the ItemStateManager as a dependency to the application
application.addDependency(ItemStateManager);

The application part is the app initializer of my code! And i inject a lot of configurators like the parsed above.

Okay! This solves the item part right ??? Like i have stateless save (session specific) and statefulll (global to all sessions).Well problem solved!!!

BUT NOT! Because in the part of doors. Lets simulate, the player unlock a door using a key so this key is consumed right? Okay, but and if the player after that back to the inspection scene ? Well i did not save the door state, so the door would be locked again in my code, so yes… I NEED TO SAVE DOOR STATE. And that was kind of WTF for me… But is that, i really need… So let’s move on in the code:

/**
 * Manages the state and rendering logic for doors in the game.
 * This class handles both stateful and stateless doors, determining
 * whether a door should be open, closed, or locked based on the player's state.
 */
class DoorStateManager {
    /**
     * Retrieves the doors for the current scene from the player's state.
     * It distinguishes between stateful and stateless doors.
     * 
     * @returns {Object} An object containing `statefull` and `stateless` doors.
     */
    static get sceneDoors() {
        const sceneName = State.player.currentScene;
        State.player.doors = State.player.doors || {};
        return {
            statefull: State.player.doors[sceneName] || {},
            stateless: window.game.doors.stateless[sceneName] || {}
        };
    }

    /**
     * Determines whether a given door is locked.
     * It checks the player's state or the default scene configuration.
     * 
     * @param {string} guid - The unique identifier of the door.
     * @param {boolean} defaultIsLocked - if has no state this should follow the base door config
     * @param {boolean} [isStateless=false] - Indicates if the door is stateless.
     * @returns {boolean} True if the door is locked, otherwise false.
     */
    static isDoorLocked(
        guid, 
        defaultIsLocked, 
        isStateless = false
    ) {
        const manager = DoorStateManager.sceneDoors;

        const doorManager = isStateless
            ? manager.stateless
            : manager.statefull;
        
        const door = doorManager[guid];
        return door ? door.isLocked : defaultIsLocked;
    }

    /**
     * Locks or unlocks a door based on the given state.
     * It updates the player's door state accordingly.
     * 
     * @param {string} doorGuid - The unique identifier of the door.
     * @param {boolean} isStateless - Indicates if the door is stateless.
     * @param {boolean} isLocked - True if the door should be locked, otherwise false.
     */
    static setDoorLockState(
        doorGuid,
        isStateless,
        isLocked
    ) {
        const manager = DoorStateManager.sceneDoors;

        const doorManager = isStateless
            ? manager.stateless
            : manager.statefull;

        console.log(manager)
        
        doorManager[doorGuid] = { isLocked };
    }

    /**
     * Configures the initial door state for the game, setting up the
     * player's doors and the global stateless doors for the current scene.
     * 
     * @param {Object} app - The application instance (or other context object).
     */
    static configure(app) {
        window.game = window.game || {};
        window.game.doors = window.game.doors || {};
        window.game.doors.stateless = window.game.doors.stateless || {};

        const sceneName = State.player.currentScene;
        const doors = DoorStateManager.sceneDoors;

        State.player.doors = State.player.doors || {};
        State.player.doors[sceneName] = doors.statefull;

        window.game.doors.stateless[sceneName] = doors.stateless;
    }
}

// Add the DoorStateManager as a dependency to the application
application.addDependency(DoorStateManager);

And probabbly i’ll have more and more things to save in the game so in my configurator i created folders to state managers

image

And i already write a lot and did not tell about some playcanvas bugs i need to bypass like that.

Conclusions

Work with Playcanvas is just great for me, what you guys have done is just incredible! And i want to contribute with this community.And for my game i’ll keep moving on doing that untill i have the 47 minigames done and the campus complete functional!.

Please if u read this complete post enter the demos and tell me things to enhance the code, or if u see that and liked some feature, ask me and i’ll give u all the code and support to implement that.

2 Likes

You can save variables in local storage with:

window.localStorage.setItem("doorsix", (Doorsix));

in this case the first “doorsix”, is the item saved in local storage, and the second “Doorsix” is the variable that is being saved.

This can later be retrieved with:

Doorsix = parseFloat(window.localStorage.getItem("doorsix"));

So just load all these variables, then have door number 6 be opened with something like:

if (Doorsix === 1) 
{
//doorstuff
}

The only downside is you have to make a variable for every door you want to save the state of in the game, but thats honestly not that bad. Personally my save system looks like:

It goes on like that for a while. It gets fairly tedious but after they are all set its not really a problem and it works very well.

Also, your game seems to be quite laggy for me. Any idea why that would be?

As soon as I spawn in the game the engine is rendering above 1.2 million tris. I turn slightly to the right and I get beyond 1.4 million. When I turn around and look at literally nothing the game is still somehow rendering over 850,000 tris. The lowest I ever got your game to render ever is 847,432 tris. Alright I reloaded the game and now its at 558,000 tris. How? I have yet to go above 20 fps, and thats while looking backwards into the void. Walking forward gives me less than 10. Now when I look away I get 16k tris. Far better, but what was causing all those tris to render the first time? And why is it so laggy still? Im sorry but I am currently at like 6 fps and I simply cant continue.

1 Like

Hey, I played the demo, weird walking feature where you move too fast you cant stop running, doors are hard to open and sometimes just buggy, I would like language options since I had no clue what was said, maybe initial setting like language would help. You got the base down though from what I can tell, keep it up.

You dont have to have each individual thing your saving be a different variable. Use a JSON schema stored in a string and parsed when you load into the game.

1 Like

I use something near to it in my save

class GameStateController {
    constructor(
        app, 
    ) {
        this.app = app;
        this.initialize();
    }

    onGameLoad(callback){
        if(window.gameLoaded)return;
        
        window.gameLoaded = true;
        callback()
    }

    loadGameState() {
        window.gameStateLoaded = true;
        const gameStateString = localStorage.getItem(`UniVerse:gameState`);

        if (!gameStateString) return;
        window.gameState = JSON.parse(gameStateString);

        const position = window.gameState.playerState.position;

        this.app.scenes.changeScene(window.gameState.playerState.currentScene, (err, entity) => {
            if (err) {
                console.error(err);
                return;
            }
            this.useGameState(position, entity);
        });
    }

    update(dt) {
        if(!window.gameState || !this.campusName)return;

        this.saveGameState();

        const currentBuilding = State.player.currentBuilding;
        State.campus.timeSpend[currentBuilding] += dt;
    }

    useGameState(position, root) {
        const player = root.findByName("player");
        if (!State.player || !State.player.currentScene || !position || !player) return;

        player.rigidbody.teleport(new pc.Vec3(position.x, position.y, position.z));
    }

    injectCampusSave(
        campusName, 
        campusSave
    ) {
        this.campusName = campusName;
        this.initialize();

        if (!window.gameState.playerState.currentCampus) {
            window.gameState.playerState.currentCampus = this.campusName;
            window.gameState.playerState.currentScene = `${this.campusName}/Campus`;
            window.gameState.playerState.currentBuilding = "Campus";
        }

        window.gameState[this.campusName] = window.gameState[this.campusName] || campusSave;

        const [campusPreffix, currentScene] = window.gameState.playerState.currentScene.split("/");

        if (window.gameState[this.campusName].campusState.initialCutscene == undefined)
            window.gameState[this.campusName].campusState.initialCutscene = window.gameState.playerState.currentScene != `${campusPreffix}/Campus`;

        const building = State.bob;

        if (`${campusPreffix}/${currentScene.toLowerCase()}` === (building ? building.toLowerCase() : "")) {
            const bob = this.app.root.findByName("Bob");
            if (bob) bob.enabled = true;
        }
    }

    initialize() {
        window.gameStateLoaded = window.gameStateLoaded || false;
        if (!window.gameStateLoaded) this.loadGameState();

        window.gameState = window.gameState || {};
        window.gameState.playerState = window.gameState.playerState || {
            entryName: undefined,
            inventory: {},
            position: {}
        };

        window.gameState.playerState.isStateFullScene = true;

        this.app.on(EVENTS.minigame.FINISHED, this.createAchievement);
        this.app.on(EVENTS.game.SAVE, this.saveGameState.bind(this));
    }

    saveGameState() {
        window.gameState.lastUpdate = Date.now();
        const campusSaveClone = { ...window.gameState[this.campusName] };
        const playerStateClone = { ...window.gameState.playerState };

        delete playerStateClone.entryName;

        const gameState = {
            playerState: playerStateClone
        };

        gameState[this.campusName] = campusSaveClone;

        localStorage.setItem(`UniVerse:gameState`, JSON.stringify(gameState));
    }
}

this is my game specific, but it creates in the window

the window.gamestate (Global user save), and inside of it inject the campus state.

My game is to be an actually “Universe”, the goal is to have 5-6 great campus to the user walk and play, and inside each campus would have custom minigames.

Exemple of Campus save:

MontesClarosState.prototype.initialize = function() {
    const save = {
        bobState: {
            predio1: "montes_claros/Predio1",
            predio2: "montes_claros/Predio2",
            predio3: "montes_claros/Predio3",
            predio6: "montes_claros/Predio6",
            predio7: "montes_claros/Predio7",
        },
        campusState: {
            campusCutScenes: {
                predio1: false,
                predio2: false,
                predio3: false,
                predio6: false,
                predio7: false,
            },
            initialCutscene: false,
            miniGames: {
                predio1: undefined,
                predio2: undefined,
                predio3: undefined,
                predio6: undefined,
                predio7: undefined,
                predio9: undefined,
            },
            seenConversations: {},
            completedMiniGames: {
                predio1: [],
                predio2: [],
                predio3: [],
                predio6: [],
                predio7: [],
            },
            timeSpend: {
                Predio1: 0,
                Predio2: 0,
                Predio3: 0,
                Predio6: 0,
                Predio7: 0,
            },
            predio1: 0,
            predio2: 0,
            predio3: 0,
            predio6: 0,
            predio7: 0,

        }
    };

    this.gameStateController = GameStateServiceProvider.gameStateController;
    this.gameStateController.injectCampusSave(
        "montes_claros",
        save
    );
};

This handle things like, how many time the user spend in each building, the talk assets the player already sees to avoid then and show different ones to make it more “alive”, and the conquests management in that campus and so on.

The frameWork

And finally i created a framework to be easy to read and write the save. is the global State class. That u can find at the config folder.

/**
 * A class for reading state information from the global `window.gameState` object.
 * Provides static getters to access player state, bob state, and campus state.
 */
class State {
  /**
   * Gets the current player state from the global `window.gameState` object.
   * @returns {Object} The player state object.
   */
  static get player() {
    return window.gameState.playerState || {};
  }

  /**
   * Sets the current player state from the global `window.gameState` object.
   */
  static set player(value) {
    window.gameState.playerState = {
        ...window.gameState.playerState,
        ...value
    };
  }

  /**
   * Gets the bob state for the current scene from the global `window.gameState` object.
   * @returns {string|undefined} The bob state string for the current campus, or `undefined` if not found.
   */
  static get bob() {
    const currentCampus = window.gameState.playerState.currentCampus;
    return window.gameState[currentCampus]?.bobState[window.gameState.playerState.currentBuilding.toLowerCase()] || undefined;
  }

  /**
   * Sets the bob state for the current scene from the global `window.gameState` object.
   */
  static set bob(value){
    if(typeof(value) !== "string"){
      console.trace();
      throw Error("bob accept string values only");
    }
    const currentCampus = window.gameState.playerState?.currentCampus;
    const currentBuilding = window.gameState.playerState?.currentBuilding?.toLowerCase();
    
    if(window.gameState[currentCampus].bobState[currentBuilding])
      window.gameState[currentCampus].bobState[currentBuilding] = value;
  }

  /**
   * Gets the campus state for the current campus from the global `window.gameState` object.
   * @returns {Object|undefined} The campus state object for the current campus, or `undefined` if not found.
   */
  static get campus() {
    const currentCampus = window.gameState.playerState.currentCampus;
    return window.gameState[currentCampus]?.campusState || {};
  }
  
  /**
   * Gets the running minigame of the player
   * @returns {Object|undefined} The running miniGame
   */
  static get miniGame(){
    return this.campus.miniGames[this.player.currentBuilding.toLowerCase()];
  }

  /**
   * Sets the running minigame of the player
   * NOTICE: this do not can change minigame core (name, state) it just add states
   */
  static set miniGame(value){
    this.campus.miniGames[this.player.currentBuilding.toLowerCase()] = {
      ...this.campus.miniGames[this.player.currentBuilding.toLowerCase()],
      ...value
    };
  }
}

This read the miniGame u are in, your player things like inventoryState and so on. if u enter the console in your browser and enter State u’ll see more.

using State.player u’ll see all your character state (itens u collected, your current inventory. Your current scene, the position and so on).

So in my game now, for me is easy to manage saves and stateless (session specific) and global states (Global to all sessions). To session specific i use window.game

And global i use the State class.

And finally all of it is saved in the localStorage. But when the player uses it on my server. In the build i inject a script that sends the save to my api to avoid things to not be saved.

1 Like

I really don’t know even how to mensure how many tris are being running, can u tell me how u did that ? And do you know how to optimize it ?

Hmmm i actually increases the player speed when he is walking for 3+ seconds


CharacterController.prototype.update = function (dt){
    if(!!this.stairClimbing)
        this.stairClimbing.rigidbody.friction = !this.isMoving ? 1 : this.initialFriction;        

    this.timeWalking = this.isMoving ? this.timeWalking + dt : 0;
    this.isMoving = false;
    
    State.player.position = State.player.isStateFullScene
        ? this.entity.getPosition()
        : undefined;
}


CharacterController.prototype.move = function (direction, dt) {
    if (direction.length() > 0) {
        this.isMoving = true;
        this.force.copy(direction).normalize().scale(this.speed * 
            (this.stairClimbing ? 3 : 1.3) * 
            (!!!this.stairClimbing && this.timeWalking > 3 ? 2 : 1));

        this.entity.rigidbody.applyForce(this.force);
    }
};

And actually changes his speed configs when is climbing stairs to avoid falling.

I did not created a button to increase the speed because it would not be a deal in the mobile version.But in general this is the character controller provided by the Playcanvas. With some adjustments in things like managing if it’s mobile or not.

All the code is in the features folder

image

Can u tell me what would u change to make it better ?

And the part of doors what’s exactly the problems u’ve founded ?

About the language options. It hardly will be implemented because this game is proposed by my university at Brazil. And the idea is people enter the game and play it to know the university and it’s courses.

But in my own future games. I’ll implement these options, i have it premade in my config folder.

/**
 * ResourceManager is a singleton class responsible for loading assets in the application.
 * It ensures that only one instance of the loader is created and provides methods to load assets by tag and name.
 */
class ResourceManager {
    /** @type {ResourceManager|null} @private */
    static #instance = null;

    /**
     * Creates an instance of ResourceManager.
     * This constructor should not be called directly. Instead, use the `ResourceManager.instance` getter or `ResourceManager.initialize(app)` method.
     * 
     * @param {pc.Application} app - The PlayCanvas application instance.
     * @throws {Error} If an instance of ResourceManager already exists.
     * @private
     */
    constructor(app) {
        if (ResourceManager.#instance) 
            throw new Error("Use the ResourceManager.instance getter to access the singleton instance.");
        
        /** @type {pc.Application} */
        this.app = app; 
    }

    /**
     * Returns the singleton instance of ResourceManager.
     * 
     * @returns {ResourceManager} The singleton instance.
     * @throws {Error} If the instance is not yet initialized.
     * @static
     */
    static get instance() {
        if (!ResourceManager.#instance) 
            throw new Error("ResourceManager instance is not yet initialized. Please call ResourceManager.initialize(app) first.");
        
        return ResourceManager.#instance;
    }

    /**
     * Initializes the singleton instance of ResourceManager with the given app.
     * If the instance is already initialized, it returns the existing instance.
     * 
     * @param {pc.Application} app - The PlayCanvas application instance.
     * @returns {ResourceManager} The initialized instance.
     * @throws {Error} If the app parameter is not provided.
     * @static
     */
    static initialize(app) {
        if (!ResourceManager.#instance) {
            if (!app) 
                throw new Error("App parameter is required for initialization.");
        
            ResourceManager.#instance = new ResourceManager(app);
        }

        return ResourceManager.#instance;
    }

    /**
     * Configures the ResourceManager by initializing it with the provided app.
     * 
     * @param {pc.Application} app - The PlayCanvas application instance.
     * @static
     */
    static configure(app){
        ResourceManager.initialize(app);
    }

    /**
     * Loads an asset by its tag and name.
     * 
     * @param {string} assetTag - The tag of the asset, which must be one of the predefined tags in the TAGS class.
     * @param {string} assetName - The name of the asset.
     * @returns {*} The loaded asset resource.
     * @throws {Error} If the asset tag is invalid or if the asset cannot be found.
     */
    loadAsset(assetTag, assetName) {
        if (!Object.values(TAGS).includes(assetTag))
            throw new Error(`Invalid assetTag: ${assetTag}. It must be one of the predefined tags in the TAGS class.`);

        const asset = this.app.assets.findByTag(assetTag)
            .filter((asset) => asset.name === assetName)[0];

        if (!asset)
            throw new Error(`Asset with name "${assetName}" and tag "${assetTag}" not found.`);

        return asset;
    }

    /**
     * Provides access to runtime operations related to entities.
     * This getter returns an object with methods for loading and interacting with entities at runtime.
     * 
     * @returns {Object} An object with runtime operations.
     */
    get runtime() {
        const app = this.app;
        return {
            /**
             * Loads all entities with the specified tag at runtime.
             * 
             * @param {string} TAG - The tag of the entities to load.
             * @returns {pc.Entity[]} An array of entities that have the specified tag.
             * @throws {Error} if the tag is invalid
             */
            loadEntities(TAG) {
                if (!Object.values(TAGS).includes(TAG))
                    throw new Error(`Invalid entityTag: ${TAG}. It must be one of the predefined tags in the TAGS class.`);
                return app.root.findByTag(TAG);
            }
        };
    }
}

application.addDependency(ResourceManager);

Using this configurator class. I select the assets and entitys to use, and it’s just use it and take the asset of that in a specific language.

TalkController.prototype.loadTalk = function (talkAsset) {
    return ResourceManager.instance
        .loadAsset(
            TAGS.TALK,
            talkAsset
        ).resource;
}

I said that because the dialog system uses this class singleton to found the asset to make the npc talk or the cutscene text.

1 Like

So from the issues I get: the door button E is there for about 3 frames and disappears, you have to click it as soon as it appears before it disappears and the door is no longer openable. Second: the movement feels like you are rolling like a ball, maybe have a certain amount of time you slide instead of how long it is now since it makes movement difficult. Jumping isn’t bad, but going up stars is hard since the fps controller you’re using has a difficult time moving up stairs, for smoother movement up stairs I recommend using a plane or box angled in a slant (or a custom triangle ramp) so the model appears as stairs but instead you can walk up them like a ramp instead of jumping on each individual stair. I’d recommend the door open range be increased so it’s easier to open them, idk what the issue with it when the button disappears you cannot open the door, but if that was fixed it would be nice also.

In the button dissapearing i actually know what it could be i was testing Playcanvas events. And i’ve change my action api to this.app.once

So u have to exit the interaction area and enter it to the event be proccessed again, thanks i’ll back this event to this.app.on!

And about the Stairs. All the official stairs uses this idea of ramping. Except by the starting area. That have not ramps but the next update the campus will be plane so this problem will not happen anymore.

Above u see the example of it’s imlementation. The collision is a plane ramp. But the render mesh is a normal stair.

About the movement. I really felt hard to make movement a good thing in Playcanvas must be because i have not experience enough with that.

About, the sliding i really think it’s not happening and if it’s i have no idea to how to fix

image

Because of this linear dumping the player should not be sliding.

Can u send me a project with a good movement system ? Would be great to me to see that and improve mine! I would apreciate it a lot

1 Like

You can see debug info with this

launch.playcanvas.com

Well this is my project here, let me know if the movement is what you are looking for.

Ravioli Burglar Simulator by 18 Aliens (itch.io)

About the link with the debug info THANKS.

And about your movement system. Yeah that’s actually better than mine, fell less sliding. U use the default Playcanvas system? If so, how many u put in the force applied? This may be the main difference.

And about your game.

I know you didn’t ask for my opinion, and I haven’t played much of your game, just the tutorial. But there’s a door there that’s static, and it seems like you remove its collision when applying the key. I have a slightly better solution for that, and it’s in the code below.

var DoorController = pc.createScript('doorController');
DoorController.attributes.add("isLocked", {
    type: "boolean",
    title: "locked",
    description: "Defines if the door is open or not",
    default: false
});

DoorController.attributes.add("isStateless", {
    type: "boolean",
    title: "stateless",
    description: "Defines if the door is stateless or not!",
    default: true
});

DoorController.attributes.add("keyName", {
    type: "string",
    description: "If door is locked the keyname that opens it",
    title: "keyname",
    default: ""
})

DoorController.prototype.initialize = function() {
    this.isRotating = false;

    this.openDoorBound = this.openDoor.bind(this);
    this.closeDoorBound = this.closeDoor.bind(this);
    this.unlockDoorBound = this.unlockDoor.bind(this);

    this.isLocked = DoorStateManager.isDoorLocked(
        this.entity.getGuid(),
        this.isLocked,
        this.isStateless
    );

    this.entity.action = this.isLocked
        ? this.unlockDoorBound
        : this.openDoorBound;

    this.targetRotation = new pc.Quat();
    this.startRotation = this.entity.getLocalRotation().clone();
    this.currentLerpTime = 0;
    this.lerpDuration = 1;
};

DoorController.prototype.update = function(dt) {
    if (!this.isRotating) return;

    this.currentLerpTime += dt;
    const percent = pc.math.clamp(this.currentLerpTime / this.lerpDuration, 0, 1);

    var newRotation = new pc.Quat();
    newRotation.slerp(this.startRotation, this.targetRotation, percent);

    this.entity.setLocalRotation(newRotation);

    if (percent === 1) {
        this.isRotating = false;
        this.currentLerpTime = 0;
        this.startRotation.copy(this.targetRotation);
    }
};

DoorController.prototype.unlockDoor = function(player) {
    const inventory = State.player.inventory.backgroundItems; 
    let hasKey = false;

    for (let row = 0; row < inventory.length; row++) {
        for (let col = 0; col < inventory[row].length; col++) {
            const item = inventory[row][col] === null 
                ? null
                : inventory[row][col].item;

            if (!(item !== null && item.name === this.keyName))continue; 
            
            --inventory[row][col].quantity;
            if(inventory[row][col].quantity <= 0)
                player.fire(EVENTS.inventory.REMOVEITEM, item);
            
            
            hasKey = true;
            break;
            
        }
        if (hasKey) break; 
    }

    if (hasKey) {
        Toast.show("Porta destrancada!");
        this.isLocked = false;
        this.entity.action = this.openDoorBound;

        DoorStateManager.setDoorLockState(
            this.entity.getGuid(),
            this.isStateless,
            false
        );
        
        return;
    }
    
    Toast.show("Está trancada");
};


DoorController.prototype.openDoor = function() {
    const currentEuler = new pc.Vec3();
    this.entity.getLocalRotation().getEulerAngles(currentEuler);

    this.targetRotation.setFromEulerAngles(currentEuler.x, currentEuler.y - 90, currentEuler.z);
    const targetEuler = new pc.Vec3();
    this.targetRotation.getEulerAngles(targetEuler);

    this.isRotating = true;
    this.entity.action = this.closeDoorBound;
}

DoorController.prototype.closeDoor = function() {
    const currentEuler = new pc.Vec3();
    this.entity.getLocalRotation().getEulerAngles(currentEuler);

    this.targetRotation.setFromEulerAngles(currentEuler.x, currentEuler.y + 90, currentEuler.z);
    const targetEuler = new pc.Vec3();
    this.targetRotation.getEulerAngles(targetEuler);

    this.isRotating = true;
    this.entity.action = this.openDoorBound;
};

Ignoring the inventory and save stuff. U can use that to make it better in your game.

The result is in my first post video the part i take the key and unlock and open the door.

1 Like

Glad to see you fixed my issues with your game! hopefully development goes well!!

1 Like

That wouldnt work for me because some rooms have a script that checks if the player is inside it and enabled or disables the entities depending on that.

var Colload = pc.createScript('colload');
Colload.prototype.initialize = function() {
this.entity.collision.on('triggerenter', this.onTriggerEnter, this);
this.entity.collision.on('triggerleave', this.onTriggerLeave, this);
this.entity.children.forEach(child =>{
child.enabled = false;
});
};
Colload.prototype.onTriggerEnter = function(entity) {
if(entity.name ==="Player"){
this.entity.children.forEach(child =>{
child.enabled = true;
});
};
};
Colload.prototype.onTriggerLeave = function(entity) {
if(entity.name ==="Player"){
this.entity.children.forEach(child =>{
child.enabled = false;
});
};
};

You can use this if you want, just give this script to a collision and all children of that collision will only be active when the player is in it.

Hmm, so for resolve that u can use this:

var InteractiveArea = pc.createScript('interactiveArea');

InteractiveArea.prototype.initialize = function() {
    this.entity.isTrigger = true;
    this.entity.collision.on(EVENTS.collision.TRIGGERENTER, this.onTriggerEnter, this);
    this.entity.collision.on(EVENTS.collision.TRIGGERLEAVE, this.onTriggerLeave, this);
};

InteractiveArea.prototype.onDestroy = function() {
    this.entity.collision.off(EVENTS.collision.TRIGGERENTER, this.onTriggerEnter, this);
    this.entity.collision.off(EVENTS.collision.TRIGGERLEAVE, this.onTriggerLeave, this);
};

InteractiveArea.prototype.onTriggerEnter = function(entity) {
    if (entity.name != "player") return;

    let action = this.entity.action;
    let currentEntity = this.entity;

    while (!action && currentEntity.parent) {
        currentEntity = currentEntity.parent;
        action = currentEntity.action;
    }


    this.app.once(EVENTS.player.ACTION, () => {
        action(entity);
    });
};

InteractiveArea.prototype.onTriggerLeave = function(entity) {
    if (entity.name != "player") return;

    this.app.off(EVENTS.player.ACTION);
};

Put this script at your collisions. And create another script that u put in the entity setting the action: like:

var ChangeSceneAction = pc.createScript('changeSceneAction');
ChangeSceneAction.attributes.add("isStateFullScene", {
    type: "boolean",
    title: "Is StateFull",
    description: "Decide if the scene state should be saved in the save",
    default: true
})

ChangeSceneAction.attributes.add("sceneName", {
    title: 'Scene Name',
    type: "string"
});

ChangeSceneAction.attributes.add('location', {
    type: 'string',
    description: "The scene position to player teleport"
});

ChangeSceneAction.prototype.initialize = function() {
    this.entity.action = () => {
        
        State.player.entryName = this.location;
        State.player.exitName = this.entity.name;

        if(this.isStateFullScene){
            State.player.currentScene = this.sceneName;
            [State.player.currentCampus, State.player.currentBuilding] = this.sceneName.split("/");
            State.player.currentBuilding = State.player.currentBuilding.split("_")[0];
        }

        State.player.isStateFullScene = this.isStateFullScene;

        this.app.scenes.changeScene(this.sceneName);
    }
};

This what makes the ‘E’ button and the interaction button appear in my ui and when the user presses that fire and player Action event that makes the entity action runs.

I use this interactive area for everything now! Take items:

var Item = pc.createScript('item');

Item.attributes.add("itemName", {
    title: 'Item name to be rendered at inventory',
    type: "string"
});

Item.attributes.add('descriptionName', {
    type: 'string',
    description: "Description name to be loaded at inventory"
});

Item.attributes.add('spriteName', {
    type: 'string',
    description: "Sprite name to be rendered at inventory"
});

Item.attributes.add('functionName', {
    type: 'string',
    title: 'interact Function Name'
});

Item.attributes.add('isUnique', {
    type: 'boolean',
    title: 'Is this item unique?',
    default: true
});

Item.attributes.add('isStateless', {
    type: 'boolean',
    title: 'Is this item stateless?',
    default: false
});

Item.attributes.add('isConsumable', {
    type: 'boolean',
    title: 'Is this item consumable?',
    default: true
});

Item.attributes.add('uses', {
    type: 'number',
    title: 'If consumable how many uses the it have?',
    default: 1
});

Item.prototype.initialize = function() {
    this.item = this.item || new InventoryItem({
        guid: this.entity.getGuid(),
        name: this.itemName,
        descriptionName: this.descriptionName,
        spriteName: this.spriteName,
        interactionName: this.functionName,
        isUnique: this.isUnique,
        isStateless: this.isStateless,
        isConsumable: this.isConsumable,
        uses: this.uses
    });

    ({
        name: this.itemName, 
        isUnique: this.isUnique, 
        isStateless: 
        this.isStateless 
    } = this.item);

    if (!ItemStateManager.shouldRenderItem(this.item.guid, this.isStateless)) {
        this.entity.enabled = false;
        return;
    }

    this.entity.action = (player) => {
        player.fire(EVENTS.inventory.INSERTITEM, this.item);
        ItemStateManager.collectItem(this.item);
        this.entity.enabled = false;
    }
};

Item.prototype.setItem = function(item) {
    this.item = item;
    ItemStateManager.dropItem(item)
}

Talk with npc:

var TalkController = pc.createScript('talkController');

TalkController.attributes.add('dialogScreen', {
    type: 'entity',
    title: 'Tela de Diálogo',
    description: 'The entity to activate when the script is initialized and hide after all messages have been displayed.'
});

TalkController.attributes.add('textEntity', {
    type: 'entity',
    title: 'Text Entity',
    description: 'The entity that contains the text element.'
});

TalkController.prototype.injection = null;

TalkController.prototype.initialize = function() {
    this.dialogViewManager = new DialogViewManager(this);
    this.miniGameStateManager = this.injection ?? new MiniGameStateManager(this);

    this.baseActionMapping = {
        "talk": this.errorHandler(this.talk.bind(this)),
        "sendToScene": this.errorHandler(this.miniGameStateManager.sendToScene.bind(this.miniGameStateManager)),
        "showOptions": this.errorHandler(this.showOptions.bind(this)),
        "initMiniGame": this.errorHandler(this.miniGameStateManager.startMiniGame.bind(this.miniGameStateManager)),
        "talkEvent": this.errorHandler(this.talkEventDispatcher.bind(this))
    };

    this.baseAllowedCallbacks = {
        "questionMiniGame": this.errorHandler((player, option, optionsDiv) => {
            if(option == "Não") this.miniGameStateManager.cancelMiniGame(player);
            else this.miniGameStateManager.confirmMiniGame(player);
            document.querySelector('body').removeChild(optionsDiv);
        }),
    }

    Object.freeze(this.baseActionMapping);
    Object.freeze(this.baseAllowedCallbacks);

    this.actionMapping = {
        ...this.baseActionMapping,
        "openLink": this.errorHandler(this.miniGameStateManager.openLink.bind(this.miniGameStateManager)),
        "finishCutScene": this.errorHandler(this.finishCutScene.bind(this))
    };

    this.allowedCallbacks = {
        ...this.baseAllowedCallbacks,
        "chooseMiniGame": this.errorHandler((player, option, optionsDiv) => {
            const miniGameName = option.replaceAll(" ", "_").toLowerCase();
            State.campus.miniGames[this.playerBuilding] = {
                name: miniGameName,
                state: "start",
            };
            document.querySelector('body').removeChild(optionsDiv);
            this.talk(player, {"talkAsset": `${State.player.currentCampus}_${this.playerBuilding}_${miniGameName}_start`});            
        }),
        "questionOpenLink": this.errorHandler((player, option, optionsDiv) => {
            const uriCourseName = State.campus.miniGames[this.playerBuilding].name;
            if(option == "Sim") this.openLink(player, {"name": uriCourseName});
            else this.miniGameStateManager.cancelMiniGame(player);
            this.app.fire(EVENTS.player.RUN, true);
            State.campus.miniGames[this.playerBuilding] = undefined;
            document.querySelector('body').removeChild(optionsDiv);
        })
    };

    this.entity.action = this.errorHandler(this.talk.bind(this));
    this.app.on(
        EVENTS.dialog.START, 
        this.errorHandler(
            this.onTalkEvent.bind(this)
        ).bind(this)
    );
};

TalkController.prototype.talkEventDispatcher = function (player, data){
    this.app.fire(
        EVENTS.dialog.START,
        player,
        data
    );
}

TalkController.prototype.onTalkEvent = function (player, {entityName, asset}){
    if(this.entity.name !== entityName)return;

    if(player === undefined)
        player = this.app.root.findByName("player");

    this.talk(player, asset);
}

TalkController.prototype.onDialogFinish = function(){
    this.app.fire(EVENTS.player.RUN, true);
}

TalkController.prototype.update = function (dt){
    this.dialogViewManager.update(dt);
}

TalkController.prototype.talk = async function (player, asset) {
    this.playerBuilding = State.player.currentBuilding.toLowerCase();
    document.exitPointerLock();
    let talkAsset = asset ? asset.talkAsset : undefined;
    talkAsset = talkAsset ? talkAsset : this.miniGameStateManager.defineTalkAsset(this.playerBuilding);

    if (talkAsset.includes('_')) 
        State.campus.seenConversations[talkAsset] = talkAsset;
    const talkJson = await this.loadTalk(talkAsset);
    if(!talkJson.messages.length) return;
    
    this.entity.fire(EVENTS.animation.START);
    this.app.fire(EVENTS.player.RUN, false);

    this.dialogViewManager.showDialog(talkJson, this.dialogScreen, this.textEntity, player);
}

TalkController.prototype.finishCutScene = function() {/** Basicly an interface for some another classes like BuildingCutScene */}

TalkController.prototype.loadTalk = function (talkAsset) {
    return ResourceManager.instance
        .loadAsset(
            TAGS.TALK,
            talkAsset
        ).resource;
}

TalkController.prototype.showOptions = function(player, {options, callbackName = "chooseMiniGame"}) {
    this.dialogViewManager.showOptions(player, options, callbackName);
};

TalkController.prototype.openLink = function (player, {name}){
    State.bob = this.playerBuilding;   
    const toUri = (str) => {
        return str
            .normalize('NFD')                      // Decomposição de caracteres acentuados
            .replaceAll("_", "-")
            .replace(/[\u0300-\u036f]/g, '')       // Remove os diacríticos
            .trim()                                // Remove espaços em branco no início e no fim
            .replace(/[^a-z0-9 -]/g, '')           // Remove caracteres especiais
            .replace(/-+/g, '-');                  // Substitui múltiplos hífens por um único hífen
    }
    const url = `https://unimontes.br/curso/${toUri(name)}`;
    window.open(url, '_blank');
}

// Error handler
TalkController.prototype.errorHandler = function(fn) {
    const app = this.app;
    const playerBuilding = State.player.currentBuilding;
    return async function(...args) {
        try {
            await fn(...args);
        } catch (error) {
            if(State.campus.miniGames[playerBuilding])State.campus.miniGames[playerBuilding] = undefined;
            app.fire(EVENTS.player.RUN, true);
            document.querySelectorAll('body div').forEach(div => div.remove());
            console.error(error);
        }
    };
};

And so on.

EVENTS

the event class is just to prevent me to write typos in the events, because the events are just strings here:

/**
 * @class EVENTS
 * @classdesc A class containing various event types used in the application.
 */
class EVENTS {
    /**
     * @static
     * @returns {Object} Game-related events.
     * @property {string} LOAD - Event triggered in the save load proccess.
     * @property {string} SAVE - Event triggered to save the game state.
     * @property {string} PAUSE - Event triggered to pause the game.
     */
    static get game() {
        return {
            ONLOAD: "universe:onload",
            SAVE: "universe:save",
            PAUSE: "universe:pause"
        };
    }

    /**
     * @static
     * @returns {Object} Player-related events.
     * @property {string} ACTION - Event triggered for player actions.
     * @property {string} RUN - Event triggered to change the player running state.
     */
    static get player() {
        return {
            ACTION: "player:action",
            RUN: "player:run",
        };
    }

    /**
     * @static
     * @returns {Object} Dialog-related events.
     * @property {string} START - Event triggered for dialog start.
     */
    static get dialog(){
        return {
            START: "talk:start"
        }
    }
    
    /**
     * @static
     * @returns {Object} Animation-related events.
     * @property {string} START - Event triggered when an animation starts.
     * @property {string} COMPLETE - Event triggered when an animation completes.
     */
    static get animation() {
        return {
            START: "animation:start",
            COMPLETE: "animation:complete",
        };
    }
    
    /**
     * @static
     * @returns {Object} Collision-related events.
     * @property {string} CONTACT - Event triggered on contact.
     * @property {string} COLLISIONSTART - Event triggered when a collision starts.
     * @property {string} COLLISIONEND - Event triggered when a collision ends.
     * @property {string} TRIGGERENTER - Event triggered when a trigger is entered.
     * @property {string} TRIGGERLEAVE - Event triggered when a trigger is left.
     */
    static get collision() {
        return {
            CONTACT: "contact",
            COLLISIONSTART: "collisionstart",
            COLLISIONEND: "collisionend",
            TRIGGERENTER: "triggerenter",
            TRIGGERLEAVE: "triggerleave",
        };
    }
    
    /**
     * @static
     * @returns {Object} First-person controller-related events.
     * @property {string} FORWARD - Event triggered when moving forward.
     * @property {string} STRAFE - Event triggered when strafing.
     * @property {string} LOOK - Event triggered when looking around.
     * @property {string} JUMP - Event triggered when jumping.
     * @property {string} STOP - Event triggered when stopping movement.
     */
    static get firstperson() {
        return {
            FORWARD: "firstperson:forward",
            STRAFE: "firstperson:strafe",
            LOOK: "firstperson:look",
            JUMP: "firstperson:jump",
            STOP: "firstperson:stop",
        };
    }
    
    /**
     * @static
     * @returns {Object} Minigame-related events.
     * @property {string} START - Event triggered when starting the minigame.
     * @property {string} RESTART - Event triggered when restart the minigame.
     * @property {string} CANCEL - Event triggered when cancel the minigame.
     * @property {string} FINISHED - Event triggered when finishing the minigame.
     */
    static get minigame() {
        return {
            START: "minigame:start",
            RESTART: "minigame:restart",
            CANCEL: "minigame:cancel",
            FINISHED: "minigame:finish",
        };
    }

    /**
     * @static
     * @returns {Object} ObjectivePointer-related events.
     * @property {string} SPAWN - event to spawn the objective indicator
     * @property {string} DESSPAWN - event to despawn the objective indicator
     */
    static get objective(){
        return {
            SPAWN: "objectivepointer:spawn",
            DESPAWN: "objectivepointer:despawn"
        }
    }
    
    /**
     * @static
     * @returns {Object} Inventory-related events.
     * @property {string} CHANGEOPENSTATE - Event triggered when the inventory's open state changes.
     * @property {string} INSERTITEM - Event triggered when an item is inserted into the inventory.
     */
    static get inventory() {
        return {
            CHANGEOPENSTATE: "inventory:changeOpenState",
            INSERTITEM: "inventory:insertItem",
            REMOVEITEM: "inventory:removeItem"
        };
    }

    /**
     * @static
     * @returns {Object} Cronometer - related events.
     * @property {string} END - The cronometer time ends
     */
    static get cronometer(){
        return {
            END: "cronometer:end"
        }
    }
}

But talking about your game, can u provide your movements config ? It’s really better than mine, and i would appreciate it

This is my first person script. A bunch of it will be useless to you because it uses variables that you dont have, so just remove that stuff.

var Bobert = pc.createScript('bobert');
var Power = 1000
Bobert.attributes.add('camera', {
type: 'entity'
});
Bobert.attributes.add('stairz', {
type: 'boolean'
});
Bobert.prototype.initialize = function() {
Camlock = 0;
Localravcount = 0;
Hasgun = 0;
this.force = new pc.Vec3();
this.eulers = new pc.Vec3();
window.lockMouse = true;
var app = this.app;
app.mouse.on("mousemove", this._onMouseMove, this);
app.mouse.on("mousedown", function () {
if(lockMouse){
app.mouse.enablePointerLock();
}
}, this);
};
Bobert.prototype.update = function(dt) {
if ((this.stairz) === true)
{
Power = 300
this.entity.rigidbody.applyImpulse(0*dt, -300*dt, 0*dt);
this.camera.camera.farClip = 32;
}
else
{
this.entity.rigidbody.applyImpulse(0*dt, -1000*dt, 0*dt);
Power = 1000;
};
var force = this.force;
var app = this.app;
var forward = this.camera.forward;
var right = this.camera.right;
var x = 0;
var z = 0;
if (app.keyboard.isPressed(pc.KEY_A)) {
x -= right.x;
z -= right.z;
}
if (app.keyboard.isPressed(pc.KEY_D)) {
x += right.x;
z += right.z;
}
if (app.keyboard.isPressed(pc.KEY_W)) {
x += forward.x;
z += forward.z;
}
if (app.keyboard.isPressed(pc.KEY_S)) {
x -= forward.x;
z -= forward.z;
}
if (app.keyboard.isPressed(pc.KEY_P)) {
canPlay = false;
onetime = true;
isPanLeft = false;
isPanRight = false;
globaldt =0;
SELF = null;
var oldHierarchy = this.app.root.findByName('Root');  
oldHierarchy.destroy(); 
this.app.loadSceneHierarchy("927086.json", function (err, parent) {
if (err) {
console.error (err);
}
window.inLevel = typeof this.app.root.findByTag("player")[0] == "object";
if(inLevel){
lockMouse = true;
this.app.mouse.enablePointerLock();
}
else{
lockMouse = false;
this.app.mouse.disablePointerLock();
}
}.bind(this));
}
if (x !== 0 && z !== 0) {
force.set(x, 0, z).normalize().scale(Power);
this.entity.rigidbody.applyForce(force);
}
this.camera.setLocalEulerAngles(this.eulers.y, this.eulers.x, 0);
};
Bobert.prototype._onMouseMove = function (e) {
if (pc.Mouse.isPointerLocked() || e.buttons[0]) {
if (Camlock > 0)  {
this.eulers.x -= 0 * e.dx;
this.eulers.y -= 0 * e.dy;
}
else  {
this.eulers.x -= Globalsens * e.dx;
this.eulers.y -= Globalsens * e.dy;
}
}
if(this.app.keyboard.isPressed(pc.KEY_Z))
{
this.eulers.x -= Globalsens * 10;
};
this.eulers.y = pc.math.clamp(this.eulers.y, -89, 89);
};

This is the ridigbody.

Also I have no idea what the scripts you sent me are supposed to do.