feat: update map and character displaying, add BoxCollider and cannon to character. Restriction of the character's movement area, shadow fix

This commit is contained in:
Mykyta Slobodianiuk
2026-06-03 18:32:57 +03:00
parent 0b469df12c
commit 4216b451f4
8 changed files with 253 additions and 73 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "playable",
"runtimeExecutable": "npm",
"runtimeArgs": ["start"],
"port": 5173
}
]
}
+1
View File
@@ -151,3 +151,4 @@ export_test/
extra_compress/ extra_compress/
preview_dist/ preview_dist/
build/ build/
.claude/
+58 -53
View File
@@ -1,5 +1,7 @@
import { CameraC_internal, JoystickC, ThreeC_internal, UpdateController } from "@24tools/playable_template"; import { CameraC_internal, JoystickC, ThreeC_internal, UpdateController } from "@24tools/playable_template";
import { AnimationAction, AnimationMixer, DoubleSide, Mesh, Object3D, Raycaster, Vector3 } from "three"; import { AnimationAction, AnimationMixer, Object3D, Vector3 } from "three";
import { Body } from "cannon-es";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
const ANIM_NAMES: Record<string, string[]> = { const ANIM_NAMES: Record<string, string[]> = {
idle: ["idle", "Idle", "IDLE", "stand", "Stand"], idle: ["idle", "Idle", "IDLE", "stand", "Stand"],
@@ -10,13 +12,7 @@ const ANIM_NAMES: Record<string, string[]> = {
enum MoveState { Idle = "idle", Walk = "walk", Run = "run" } enum MoveState { Idle = "idle", Walk = "walk", Run = "run" }
const CHAR_RADIUS = 0.35; const CHAR_RADIUS = 0.35;
// Three ray heights: ankles, waist, shoulders — catches short ledges and tall walls
const RAY_HEIGHTS = [0.3, 1.0, 1.6];
const _raycaster = new Raycaster();
const _rayOrigin = new Vector3();
const _rayDir = new Vector3();
const _movement = new Vector3();
const _inputTarget = new Vector3(); const _inputTarget = new Vector3();
export class PlayerC { export class PlayerC {
@@ -24,14 +20,16 @@ export class PlayerC {
static acceleration = 10; static acceleration = 10;
static rotateSpeed = 8; static rotateSpeed = 8;
static collidables: Object3D[] = [];
private static mesh: Object3D; private static mesh: Object3D;
private static body: Body; // cannon physics body (collision + gravity)
private static batInHand: Object3D | null = null; // GLB node "Tool_1" — bat held in the hand
private static batOnBack: Object3D | null = null; // GLB node "Tool_2" — bat holstered on the back
private static mixer: AnimationMixer; private static mixer: AnimationMixer;
private static actions = new Map<string, AnimationAction>(); private static actions = new Map<string, AnimationAction>();
private static state = MoveState.Idle; private static state = MoveState.Idle;
private static currentAction: AnimationAction | null = null; private static currentAction: AnimationAction | null = null;
// Desired planar velocity (x/z). The body's y is left to gravity.
static velocity = new Vector3(); static velocity = new Vector3();
private static inputDir = new Vector3(); private static inputDir = new Vector3();
@@ -41,26 +39,51 @@ export class PlayerC {
static init(mesh: Object3D) { static init(mesh: Object3D) {
this.mesh = mesh; this.mesh = mesh;
this.createBody();
this.setupWeapon();
this.setupAnimations(); this.setupAnimations();
this.setupJoystick(); this.setupJoystick();
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
} }
// Call after setting collidables so raycasting works regardless of // Toggle the bat between the back (idle/normal) and the hand (attack).
// which way the map GLB normals face // Call setBatInHand(true) when the attack animation starts, and
static prepareCollidables() { // setBatInHand(false) when it ends.
this.collidables.forEach(obj => { static setBatInHand(inHand: boolean) {
obj.traverse(child => { if (this.batInHand) this.batInHand.visible = inHand;
if (!(child instanceof Mesh)) return; if (this.batOnBack) this.batOnBack.visible = !inHand;
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach(m => { m.side = DoubleSide; });
});
});
console.log("[PlayerC] Collidables ready:", this.collidables.length, "root(s)");
} }
// ── Private ──────────────────────────────────────────────────────────────── // ── Private ────────────────────────────────────────────────────────────────
private static setupWeapon() {
this.batInHand = this.mesh.getObjectByName("Tool_1") ?? null;
this.batOnBack = this.mesh.getObjectByName("Tool_2") ?? null;
this.setBatInHand(false); // normal state: bat rests on the back
}
private static createBody() {
// Sphere collider (PhysicsLayer.Player makes PhysicsBody use a Sphere shape).
const pb = new PhysicsBody(
this.mesh,
false, // not a trigger
1, // dynamic
PhysicsLayer.Player,
PhysicsLayer.Wall, // collide with floor + boundary walls
CHAR_RADIUS
);
this.body = pb.getPhysicsBody();
// A character must not topple — keep it upright and let us steer it directly.
this.body.fixedRotation = true;
this.body.updateMassProperties();
this.body.linearDamping = 0; // we set planar velocity explicitly every frame
// Rest the sphere on the floor at the spawn point.
this.body.position.set(this.mesh.position.x, CHAR_RADIUS + 0.05, this.mesh.position.z);
this.body.velocity.set(0, 0, 0);
}
private static setupAnimations() { private static setupAnimations() {
const gltf = ThreeC_internal.getMesh("character"); const gltf = ThreeC_internal.getMesh("character");
this.mixer = new AnimationMixer(this.mesh); this.mixer = new AnimationMixer(this.mesh);
@@ -110,20 +133,29 @@ export class PlayerC {
// equivalent to: newLen = len^CURVE, then normalize and scale // equivalent to: newLen = len^CURVE, then normalize and scale
} }
if (this.inputDir.lengthSq() > 1) this.inputDir.normalize(); if (this.inputDir.lengthSq() > 1) this.inputDir.normalize();
} }
private static update(delta: number) { private static update(delta: number) {
// Smoothly ramp the desired planar velocity toward the input target.
_inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed); _inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed);
this.velocity.lerp(_inputTarget, Math.min(1, this.acceleration * delta)); this.velocity.lerp(_inputTarget, Math.min(1, this.acceleration * delta));
const speed = this.velocity.length(); // Drive the body on x/z; leave y to gravity so the player rests on the
// floor and cannon resolves collisions with the boundary walls (sliding
// along them comes for free).
this.body.velocity.x = this.velocity.x;
this.body.velocity.z = this.velocity.z;
if (speed > 0.01) { // Sync the mesh to the body. The body origin is the sphere centre, so the
_movement.copy(this.velocity).multiplyScalar(delta); // mesh (origin at the feet) is dropped by the radius.
this.tryMove(_movement); this.mesh.position.set(
} this.body.position.x,
this.body.position.y - CHAR_RADIUS,
this.body.position.z,
);
const speed = this.velocity.length();
if (speed > 0.05) { if (speed > 0.05) {
const targetAngle = Math.atan2(this.velocity.x, this.velocity.z); const targetAngle = Math.atan2(this.velocity.x, this.velocity.z);
@@ -148,33 +180,6 @@ export class PlayerC {
} }
} }
private static tryMove(movement: Vector3) {
if (!this.hasCollision(movement)) {
this.mesh.position.add(movement);
}
}
// Cast three rays (ankles / waist / shoulders) so short ledges and
// tall walls are both detected. Using DoubleSide (set in prepareCollidables)
// means detection works regardless of which way face normals point in the GLB.
private static hasCollision(movement: Vector3): boolean {
if (this.collidables.length === 0) return false;
const len = movement.length();
if (len < 0.0001) return false;
_rayDir.copy(movement).divideScalar(len);
const threshold = len + CHAR_RADIUS;
for (let i = 0; i < RAY_HEIGHTS.length; i++) {
_rayOrigin.copy(this.mesh.position);
_rayOrigin.y += RAY_HEIGHTS[i];
_raycaster.set(_rayOrigin, _rayDir);
const hits = _raycaster.intersectObjects(this.collidables, true);
if (hits.length > 0 && hits[0].distance < threshold) return true;
}
return false;
}
private static findAction(state: MoveState): AnimationAction | null { private static findAction(state: MoveState): AnimationAction | null {
for (const name of ANIM_NAMES[state]) { for (const name of ANIM_NAMES[state]) {
const action = this.actions.get(name); const action = this.actions.get(name);
+165 -3
View File
@@ -1,11 +1,27 @@
import { ThreeC } from "./ThreeC"; import { ThreeC } from "./ThreeC";
import { InputC } from "@24tools/playable_template"; import { InputC, Physics_internal } from "@24tools/playable_template";
import { Object3D } from "three"; import { Box3, Mesh, Object3D, Vector3 } from "three";
import { Body, Box, Vec3 } from "cannon-es";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
export class TestSceneC { export class TestSceneC {
static mapObject: Object3D; static mapObject: Object3D;
static characterObject: Object3D; static characterObject: Object3D;
// Top-level groups the artist authored inside the "Map" node of the GLB.
static environment: Object3D | null = null; // Ground_ — visual ground/road/borders, the only thing rendered
static colliderGroup: Object3D | null = null; // Colliders — invisible BoxCollider proxies → cannon bodies
static lootableGroup: Object3D | null = null; // Lootable — interactive crates, hidden until activated per-crate
// World Y of the walkable sand surface (the floor proxy is aligned to it).
static groundY = 0;
// Static cannon bodies built from the map's collider proxies.
static mapBodies: PhysicsBody[] = [];
// Invisible perimeter walls keeping the player on the floor (the GLB has no wall colliders).
static boundaryBodies: Body[] = [];
static init() { static init() {
this.loadScene(); this.loadScene();
this.loadCharacter(); this.loadCharacter();
@@ -17,15 +33,161 @@ export class TestSceneC {
private static loadScene() { private static loadScene() {
this.mapObject = ThreeC.getObject("scene"); this.mapObject = ThreeC.getObject("scene");
ThreeC.setShadowsStateForChildren(this.mapObject, true, true);
// Resolve the named groups baked into the GLB.
this.environment = this.mapObject.getObjectByName("Ground_") ?? null;
this.colliderGroup = this.mapObject.getObjectByName("Colliders") ?? null;
this.lootableGroup = this.mapObject.getObjectByName("Lootable") ?? null;
const uiGroup = this.mapObject.getObjectByName("UI") ?? null;
const uiWood = this.mapObject.getObjectByName("UI_Wood") ?? null;
// Add the whole graph so every world transform stays intact (the collider
// proxies' world positions depend on the full parent chain), then hide
// everything that is not the static environment.
ThreeC.addToScene(this.mapObject); ThreeC.addToScene(this.mapObject);
this.mapObject.updateMatrixWorld(true);
// Build static physics from the invisible collider proxies first…
this.buildMapPhysics();
this.alignFloorToGround();
this.buildBoundaryWalls();
// …then hide the proxies and the groups we are not activating yet.
if (this.colliderGroup) this.colliderGroup.visible = false; // physics-only, never rendered
if (this.lootableGroup) this.lootableGroup.visible = false; // interactive — enabled later, per crate
if (uiGroup) uiGroup.visible = false; // playable UI is HTML/CSS, not in-world
if (uiWood) uiWood.visible = false;
// Only the environment is rendered with shadows.
if (this.environment) {
ThreeC.setShadowsStateForChildren(this.environment, true, true);
} else {
console.warn("[Map] 'Ground_' group not found — nothing to render as environment");
}
}
// One static (mass 0) CANNON.Box per BoxCollider proxy under the Colliders group.
// For this map that is a single floor slab; per-crate colliders live under
// Lootable and are built separately when crates are activated.
private static buildMapPhysics() {
if (!this.colliderGroup) {
console.warn("[Map] No 'Colliders' group found in GLB — map has no physics");
return;
}
this.colliderGroup.traverse(child => {
if (!(child instanceof Mesh)) return;
const body = new PhysicsBody(
child,
false, // not a trigger
0, // mass 0 → static
PhysicsLayer.Wall,
PhysicsLayer.Player // collides with the player (other masks added when needed)
);
this.mapBodies.push(body);
});
console.log(`[Map] Static collider bodies built: ${this.mapBodies.length}`);
}
// The authored floor collider sits a few cm above the visual sand, so the
// player would rest slightly floating. Measure the sand surface and shift the
// floor body so its top matches it — feet then sit exactly on the ground,
// independent of the collision sphere radius.
private static alignFloorToGround() {
if (!this.environment || this.mapBodies.length === 0) return;
const sand = this.environment.getObjectByName("M_Floor_Sand") ?? this.environment;
this.groundY = new Box3().setFromObject(sand).max.y;
for (const pb of this.mapBodies) {
const body = pb.getPhysicsBody();
const half = (body.shapes[0] as Box).halfExtents.y;
const top = body.position.y + half;
body.position.y += this.groundY - top;
}
console.log(`[Map] Floor aligned to sand surface Y=${this.groundY.toFixed(3)}`);
}
// The GLB only authors a floor collider, so the play area would be unbounded.
// Build four thin static walls around the floor's footprint to keep the
// player in. Bounds are read from the floor proxy so the walls always match
// the authored map, even if it changes.
private static buildBoundaryWalls() {
if (!this.colliderGroup || !Physics_internal.physicsWorld) return;
const bounds = new Box3().setFromObject(this.colliderGroup);
if (bounds.isEmpty()) return;
const min = bounds.min;
const max = bounds.max;
const sizeX = max.x - min.x;
const sizeZ = max.z - min.z;
const cx = (min.x + max.x) / 2;
const cz = (min.z + max.z) / 2;
const t = 0.5; // wall thickness
const h = 3; // wall height
const midY = max.y + h / 2; // sits on top of the floor
// [centerX, centerZ, halfX, halfZ] — height is shared. Thickness overlaps
// at corners (+t) so there are no gaps.
const specs: [number, number, number, number][] = [
[cx, max.z + t / 2, sizeX / 2 + t, t / 2], // +Z
[cx, min.z - t / 2, sizeX / 2 + t, t / 2], // -Z
[max.x + t / 2, cz, t / 2, sizeZ / 2 + t], // +X
[min.x - t / 2, cz, t / 2, sizeZ / 2 + t], // -X
];
for (const [px, pz, hx, hz] of specs) {
const body = new Body({
mass: 0,
shape: new Box(new Vec3(hx, h / 2, hz)),
collisionFilterGroup: PhysicsLayer.Wall,
collisionFilterMask: PhysicsLayer.Player,
});
body.position.set(px, midY, pz);
Physics_internal.physicsWorld.addBody(body);
this.boundaryBodies.push(body);
}
console.log(`[Map] Boundary walls built: ${this.boundaryBodies.length} (floor ${sizeX.toFixed(1)}×${sizeZ.toFixed(1)})`);
} }
private static loadCharacter() { private static loadCharacter() {
this.characterObject = ThreeC.getObject("character"); this.characterObject = ThreeC.getObject("character");
this.configureCharacterLoadout();
ThreeC.setShadowsStateForChildren(this.characterObject, true, true); ThreeC.setShadowsStateForChildren(this.characterObject, true, true);
this.characterObject.position.set(0, 0.1, -8); this.characterObject.position.set(0, 0.1, -8);
this.characterObject.scale.setScalar(1); this.characterObject.scale.setScalar(1);
ThreeC.addToScene(this.characterObject); ThreeC.addToScene(this.characterObject);
this.liftBlobShadow();
}
// The baked blob shadow sits at exactly the sand height, which z-fights with
// the ground (the depth test flips per-pixel as the view moves → flicker),
// even though the blob doesn't write depth. Raise it a few cm so it is
// unambiguously in front of the sand. The rig is heavily scaled internally,
// so convert the world-space lift through the parent's world scale.
private static liftBlobShadow() {
const blob = this.characterObject.getObjectByName("Shadow");
if (!blob || !blob.parent) return;
blob.parent.updateWorldMatrix(true, false);
const worldScaleY = blob.parent.getWorldScale(new Vector3()).y || 1;
blob.position.y += 0.04 / worldScaleY; // ~4 cm above the sand
}
// The character GLB ships its full loadout visible at once. Here we only hide
// the pistol setup: the skinned pistol (node "Character_Pistol" — that's the
// gun, not a second body) and the separate "Bullet" VFX root (muzzle flash /
// tracer). Kept in the graph (not removed) so a future "draw pistol" state
// can re-enable them. The bat (Tool_1 in hand / Tool_2 on back) is managed by
// PlayerC, since which one is shown depends on the melee state.
private static configureCharacterLoadout() {
for (const name of ["Character_Pistol", "Bullet"]) {
const obj = this.characterObject.getObjectByName(name);
if (obj) obj.visible = false;
else console.warn(`[Character] node '${name}' not found while hiding loadout`);
}
} }
} }
+5 -13
View File
@@ -46,23 +46,15 @@ export class ThreeC extends ThreeC_internal {
let dirLight = this.defaultDirectionalLight; let dirLight = this.defaultDirectionalLight;
dirLight.position.set(8, 10, 4); //default; light shining from top dirLight.position.set(8, 10, 4); //default; light shining from top
let d = 10;
dirLight.shadow.camera.left = -d;
dirLight.shadow.camera.right = d;
dirLight.shadow.camera.top = d;
dirLight.shadow.camera.bottom = -d;
dirLight.target.position.set(0, 0, 0); dirLight.target.position.set(0, 0, 0);
dirLight.castShadow = true;
// Real-time shadows are disabled on purpose. The character ships its own
// baked "blob" shadow inside the GLB (the "Shadow" mesh), which is cheaper
// and never shimmers. The directional light is kept only for shading.
dirLight.castShadow = false;
this.addToScene(dirLight); this.addToScene(dirLight);
this.addToScene(this.defaultAmbientLight); this.addToScene(this.defaultAmbientLight);
this.addToScene(dirLight.target); this.addToScene(dirLight.target);
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 200;
} }
} }
+2 -2
View File
@@ -16,9 +16,9 @@ window.setupConfig = async function (config) {
redirectOptions: {}, redirectOptions: {},
ticker: Template3d.ticker, ticker: Template3d.ticker,
debug: { debug: {
physics: false, physics: true,
// set true if you want to enable physics debugger // set true if you want to enable physics debugger
logger: false // set true if you want to enable logger logger: true // set true if you want to enable logger
} }
}).init({ }).init({
config: config || formConfigForPlayable(formConfigUI({ config: config || formConfigForPlayable(formConfigUI({
+2 -2
View File
@@ -22,9 +22,9 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
}); });
} }
// Player is now a cannon body — collisions with the floor and boundary
// walls are handled by the physics world (no more raycasting).
PlayerC.init(TestSceneC.characterObject); PlayerC.init(TestSceneC.characterObject);
PlayerC.collidables = [TestSceneC.mapObject];
PlayerC.prepareCollidables();
FollowCameraC.init(TestSceneC.characterObject); FollowCameraC.init(TestSceneC.characterObject);
@@ -25,6 +25,15 @@ export const beforeResourcesLoadedCb = () => {
Physics_internal.init(new Vec3(0, -9.81, 0)); Physics_internal.init(new Vec3(0, -9.81, 0));
// The player is steered by setting its velocity directly every frame, so
// ground friction would only fight the intended motion (it dropped the
// effective speed to ~0.84 of 4 m/s). Disable it globally; boundary walls
// still block the player via the normal contact constraint. When crates are
// added later, give them their own ContactMaterial if they need friction.
if (Physics_internal.physicsWorld) {
Physics_internal.physicsWorld.defaultContactMaterial.friction = 0;
}
Template.updateVariableConfig.addDelegate(([category, variable, value]) => { Template.updateVariableConfig.addDelegate(([category, variable, value]) => {
if (category === "global") { if (category === "global") {
switch (variable) { switch (variable) {