Creating a playable scene

Primitive third-person player example

In this example, we will reflect the minimum important concepts and knowledge to understand how to work with the library.


Writing a custom module

We need to have a centralized way to determine which key is pressed. For this, CoreContextModule is a great fit. We will subscribe to the necessary DOM events in useCtx and unsubscribe in the returned cleanup function. It will allow us to elegantly and centrally determine which button is pressed, which we will use in other features.

InputKeyModule.js
import * as KVY from "@vladkrutenyuk/three-kvy-core";

export class InputKeyModule extends KVY.CoreContextModule {
    keys = new Set();
    isKeyDown = (key) => this.keys.has(key);

    useCtx(ctx) {
        const onKeyDown = (e) => this.keys.add(e.code);
        const onKeyUp = (e) => this.keys.delete(e.code);
        const onBlur = () => this.keys.clear();

        const dom = ctx.three.renderer.domElement;
        dom.addEventListener("keydown", onKeyDown);
        dom.addEventListener("keyup", onKeyUp);
        dom.addEventListener("blur", onBlur);

        return () => {
            dom.removeEventListener("keydown", onKeyDown);
            dom.removeEventListener("keyup", onKeyUp);
            dom.removeEventListener("blur", onBlur);
        }
    }
}

Create a player using features

Movement

Now let's create a feature SimpleMovement, which will move the object to which we add it based on the pressed keys.

We will also use props in the constructor to demonstrate how to pass props into features.

SimpleMovement.js
import * as KVY from "@vladkrutenyuk/three-kvy-core";

export class SimpleMovement extends KVY.Object3DFeature {
    speed = 10;

    constructor(object, props) {
        super(object);
        this.speed = props.speed;
    }

    onBeforeRender(ctx) {
        const offset = this.speed * ctx.deltaTime;
        const pos = this.object.position;

        const key = ctx.modules.input.isKeyDown;

        if (key('KeyW')) pos.z -= offset;
        if (key('KeyS')) pos.z += offset;
        if (key('KeyD')) pos.x += offset;
        if (key('KeyA')) pos.x -= offset;
    }
}
  • We use the lifecycle method onBeforeRender, which is called every time before the render call.
  • onBeforeRender provides us with the ctx argument, through which we can access various properties and modules that we have attached.
  • We take ctx.deltaTime to correctly calculate the position offset.
  • We access the object to which the feature is attached through this.object and change its position.

Camera follows player

Now we need the camera to follow the player as we move. Let's create a simple feature that attaches the camera to the object and positions it according to the specified parameters.

CameraFollow.js
import * as KVY from "@vladkrutenyuk/three-kvy-core";
import * as THREE from "three";

export class CameraFollow extends KVY.Object3DFeature {
    constructor(object, props) {
        super(object);
        this.offset = props.offset;
        this.lookAt = props.lookAt;
    }

    useCtx(ctx) {
        const camera = ctx.three.camera;
        camera.position.fromArray(this.offset);
        camera.lookAt(new THREE.Vector3().fromArray(this.lookAt));

        this.object.add(camera);

        return () => {
            this.object.remove(camera);
        }
    }
}

Graphics for player

We can also put the initialization of three.js graphics for the player into a separate feature to separate the code and conveniently automatically clean up memory when the feature is destroyed.

PlayerGraphics.js
export class PlayerGraphics extends KVY.Object3DFeature {
    constructor(object) {
        super(object);
        const capsule = new THREE.Mesh(new THREE.CapsuleGeometry(0.5), new THREE.MeshNormalMaterial());
        capsule.position.y = 1;
        
        const axes = new THREE.AxesHelper(5);
        axes.position.y = 0.1;
        
        this.components = [capsule, axes];
        this.object.add(...this.components);
    }

    onDestroy() {
        for (const component of this.components) {
            component.removeFromParent();
            component.geometry.dispose();
            component.material.dispose();
        }
    }
}

Finally, build a scene!

  • Initialize the context ctx (CoreContext) with the InputKeyModule module under the key input.
  • Mount the context ctx.three.mount to the specified HTML element, thus the three.js rendering canvas will be added to this element. If something, all the logic for resizing, etc. is already present inside, so we don't need to worry about it. And start the animation loop with ctx.run().
  • Create a 3D object player and add the features we wrote to it using the static factory method addFeature. It is important to specify the class (constructor) of the feature, not the instance.
  • Add it to ctx.root, so the context is attached to the features we added to it.
  • Set up the external appearance of the scene to make it easier to see what is happening.
main.js
import * as KVY from "@vladkrutenyuk/three-kvy-core";
import * as THREE from "three";
import { InputKeyModule } from "./InputKeyModule.js";
import { SimpleMovement } from "./SimpleMovement.js";
import { CameraFollow } from "./CameraFollow.js";
import { PlayerGraphics } from "./PlayerGraphics.js";

const ctx = KVY.CoreContext.create(THREE,
	{ input: new InputKeyModule() },
	{ renderer: { antialias: true } },
);

ctx.three.mount(document.querySelector("#canvas-container"));
ctx.run();

// player
const player = new THREE.Group();
KVY.addFeature(player, SimpleMovement, { speed: 6 });
KVY.addFeature(player, CameraFollow, { offset: [0, 4, 5], lookAt: [0, 1.5, 0] });
KVY.addFeature(player, PlayerGraphics);

ctx.root.add(player);

// scene graphics
const scene = ctx.three.scene;
const bgColor = 0x202020;
scene.background = new THREE.Color(bgColor);
scene.fog = new THREE.Fog(bgColor, 8, 30);
scene.add(new THREE.GridHelper(100, 100));
three-kvy-core ...