I’m currently working on a game built using Noa.js and Babylon.js. In the existing entity.js file from the project template, the player is created using a simple box mesh.
I’m trying to replace this box with a .glb model to serve as the main player character. I’ve successfully loaded the GLB model into the scene, but it spawns separately and doesn’t inherit the behavior or properties of the existing player (like movement, controls, or collision handling).
What’s the correct way to replace the current box mesh used for the player entity with a GLB model, while ensuring it functions as the main player (movement, collision detection, and controls) within the Noa.js engine?
I’ll attach my current implementation for reference in the comments — would really appreciate if someone could review it and help me identify where I’m going wrong.
Thanks in advance!
Here is my code:
import * as BABYLON from '@babylonjs/core';
import '@babylonjs/loaders';
import { noa } from './engine';
import { setMeshShadows } from './shadows';
import { blockIDs } from './registration';
import blastImg from './assets/blast.png';
let sheepMesh = null;
let gameActive = true;
let explosionSound = new Audio('./assets/explosion.mp3');
const npcSheepEntities = [];
/**
* Convert 3D mesh position to 2D screen coordinates.
*/
function toScreenPosition(mesh, scene) {
if (!mesh || !scene || !scene.activeCamera) {
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
}
const worldMatrix = mesh.getWorldMatrix();
const transformMatrix = scene.getTransformMatrix();
const camera = scene.activeCamera;
const engine = scene.getEngine();
const viewport = camera.viewport.toGlobal(engine.getRenderWidth(), engine.getRenderHeight());
const projected = BABYLON.Vector3.Project(
mesh.position,
worldMatrix,
transformMatrix,
viewport
);
return { x: projected.x, y: projected.y };
}
/**
* Display a blast effect at given screen position.
*/
function showBlastEffect(screenX = window.innerWidth / 2, screenY = window.innerHeight / 2) {
return new Promise((resolve) => {
const blastElement = document.createElement('img');
blastElement.src = blastImg;
blastElement.style.position = 'fixed';
blastElement.style.top = `${screenY}px`;
blastElement.style.left = `${screenX}px`;
blastElement.style.transform = 'translate(-50%, -50%)';
blastElement.style.pointerEvents = 'none';
blastElement.style.zIndex = '9999';
blastElement.style.width = '300px';
blastElement.style.height = '300px';
blastElement.style.opacity = '1';
blastElement.style.transition = 'opacity 1s ease-out';
document.body.appendChild(blastElement);
setTimeout(() => { blastElement.style.opacity = '0'; }, 300);
setTimeout(() => {
document.body.removeChild(blastElement);
resolve();
}, 1100);
});
}
/**
* Load a GLB model and attach it to a given entity.
* Mesh follows entity position in game loop.
*/
async function loadAndAttachGLBToEntity(entityId, modelPath, modelFilename, options = {}) {
const scene = noa.rendering.getScene();
const dat = noa.entities.getPositionData(entityId);
const { width: w, height: h, position: pos } = dat;
const {
scaleMultiplier = 1,
yOffset = 0
} = options;
return new Promise((resolve, reject) => {
BABYLON.SceneLoader.ImportMesh(
null,
modelPath,
modelFilename,
scene,
(meshes) => {
const visualMesh = meshes.find(m => m.getTotalVertices && m.getTotalVertices() > 0) || meshes[0];
if (!visualMesh) return reject(new Error('No visible mesh found in GLB model.'));
visualMesh.position.set(pos[0], pos[1] + h / 2 + yOffset, pos[2]);
visualMesh.scaling = new BABYLON.Vector3(w, h, w).scale(scaleMultiplier);
visualMesh.material = noa.rendering.makeStandardMaterial();
visualMesh.visibility = 1;
noa.entities.addComponent(entityId, noa.entities.names.mesh, {
mesh: visualMesh,
offset: [0, h / 2 + yOffset, 0],
});
setMeshShadows(visualMesh, true);
if (entityId === noa.playerEntity) {
sheepMesh = visualMesh;
}
noa.on('beforeRender', () => {
if (!gameActive) return;
const data = noa.entities.getPositionData(entityId);
if (!data) return;
visualMesh.position.set(
data.position[0],
data.position[1] + yOffset,
data.position[2]
);
if (entityId === noa.playerEntity) {
checkFenceCollision(data.position[0], data.position[1], data.position[2]);
}
});
resolve(visualMesh);
},
null,
(scene, message, exception) => {
console.error('Error loading GLB model:', message, exception);
reject(exception);
}
);
});
}
/**
* Collision detection between player and fence blocks.
*/
function checkFenceCollision(x, y, z) {
if (!gameActive) return;
const blockBelow = noa.getBlock(x, y, z);
const blockAtFeet = noa.getBlock(x, y + 0.5, z);
if (blockBelow === blockIDs.fence || blockAtFeet === blockIDs.fence) {
endGame();
}
}
/**
* Update sheep counter in UI.
*/
function updateSheepCount() {
const sheepCountElement = document.querySelector('#sheep-counter .counter-value');
if (sheepCountElement) {
sheepCountElement.textContent = npcSheepEntities.length.toString();
}
}
/**
* End the game — hide sheep mesh, play sound and show blast.
*/
async function endGame() {
if (!gameActive) return;
gameActive = false;
const scene = noa.rendering.getScene();
let screenPos = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
if (sheepMesh) {
screenPos = toScreenPosition(sheepMesh, scene);
sheepMesh.setEnabled(false);
}
explosionSound.play();
await showBlastEffect(screenPos.x, screenPos.y);
document.getElementById('end-game-screen').style.display = 'flex';
document.getElementById('game-ui').style.display = 'none';
}
/**
* Spawn a single NPC sheep entity.
*/
async function spawnNPCSheep(startX, startY, startZ) {
const eid = noa.entities.add([startX, startY, startZ], 0.5, 1.2);
npcSheepEntities.push(eid);
await loadAndAttachGLBToEntity(eid, '/castle/', 'sheep.glb', {
scaleMultiplier: 0.4,
yOffset: 0,
});
updateSheepCount();
// Give sheep circular wandering movement
const centerX = startX + (Math.random() - 0.5) * 100;
const centerZ = startZ + (Math.random() - 0.5) * 100;
const radius = 5 + Math.random() * 15;
const angularSpeed = 0.0005 + Math.random() * 0.001;
const angleOffset = Math.random() * 2 * Math.PI;
noa.on('beforeRender', () => {
if (!gameActive) return;
const time = performance.now();
const angle = angleOffset + angularSpeed * time;
const x = centerX + Math.cos(angle) * radius;
const z = centerZ + Math.sin(angle) * radius;
const currentY = noa.entities.getPosition(eid)[1];
noa.entities.setPosition(eid, [x, currentY, z]);
});
}
/**
* Spawn a herd of NPC sheeps around player.
*/
async function spawnNPCSheepHerd(centerX, centerY, centerZ) {
for (let i = 0; i < 5; i++) {
const randX = centerX + (Math.random() - 0.5) * 160;
const randZ = centerZ + (Math.random() - 0.5) * 160;
await spawnNPCSheep(randX, centerY, randZ);
}
}
/**
* Initialize player entity and NPC herd after game tick.
*/
noa.once('tick', async () => {
try {
const eid = noa.playerEntity;
if (!eid) throw new Error('Player entity not found');
// Replace box mesh with GLB model for player
await loadAndAttachGLBToEntity(eid, '/castle/', 'sheep.glb', {
scaleMultiplier: 0.4,
yOffset: 0
});
// Adjust camera follow position for player sheep
const sheepHeight = noa.ents.getPositionData(eid).height;
const eyeOffset = 0.8 * sheepHeight;
noa.ents.removeComponent(noa.camera.cameraTarget, 'followsEntity');
noa.ents.addComponent(noa.camera.cameraTarget, 'followsEntity', {
entity: eid,
offset: [0, eyeOffset, 0],
});
// Spawn NPC herd
const pos = noa.entities.getPosition(eid);
await spawnNPCSheepHerd(pos[0], pos[1], pos[2]);
console.log('Player sheep loaded and NPC herd spawned.');
} catch (error) {
console.error('Failed to initialize game:', error);
}
});
https://github.com/fenomas/noa-examples
I clone the code from here