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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "playable",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["start"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -151,3 +151,4 @@ export_test/
|
||||
extra_compress/
|
||||
preview_dist/
|
||||
build/
|
||||
.claude/
|
||||
+58
-53
@@ -1,5 +1,7 @@
|
||||
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[]> = {
|
||||
idle: ["idle", "Idle", "IDLE", "stand", "Stand"],
|
||||
@@ -10,13 +12,7 @@ const ANIM_NAMES: Record<string, string[]> = {
|
||||
enum MoveState { Idle = "idle", Walk = "walk", Run = "run" }
|
||||
|
||||
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();
|
||||
|
||||
export class PlayerC {
|
||||
@@ -24,14 +20,16 @@ export class PlayerC {
|
||||
static acceleration = 10;
|
||||
static rotateSpeed = 8;
|
||||
|
||||
static collidables: 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 actions = new Map<string, AnimationAction>();
|
||||
private static state = MoveState.Idle;
|
||||
private static currentAction: AnimationAction | null = null;
|
||||
|
||||
// Desired planar velocity (x/z). The body's y is left to gravity.
|
||||
static velocity = new Vector3();
|
||||
private static inputDir = new Vector3();
|
||||
|
||||
@@ -41,26 +39,51 @@ export class PlayerC {
|
||||
|
||||
static init(mesh: Object3D) {
|
||||
this.mesh = mesh;
|
||||
this.createBody();
|
||||
this.setupWeapon();
|
||||
this.setupAnimations();
|
||||
this.setupJoystick();
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// Call after setting collidables so raycasting works regardless of
|
||||
// which way the map GLB normals face
|
||||
static prepareCollidables() {
|
||||
this.collidables.forEach(obj => {
|
||||
obj.traverse(child => {
|
||||
if (!(child instanceof Mesh)) return;
|
||||
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)");
|
||||
// Toggle the bat between the back (idle/normal) and the hand (attack).
|
||||
// Call setBatInHand(true) when the attack animation starts, and
|
||||
// setBatInHand(false) when it ends.
|
||||
static setBatInHand(inHand: boolean) {
|
||||
if (this.batInHand) this.batInHand.visible = inHand;
|
||||
if (this.batOnBack) this.batOnBack.visible = !inHand;
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const gltf = ThreeC_internal.getMesh("character");
|
||||
this.mixer = new AnimationMixer(this.mesh);
|
||||
@@ -110,20 +133,29 @@ export class PlayerC {
|
||||
// equivalent to: newLen = len^CURVE, then normalize and scale
|
||||
}
|
||||
|
||||
|
||||
if (this.inputDir.lengthSq() > 1) this.inputDir.normalize();
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
// Smoothly ramp the desired planar velocity toward the input target.
|
||||
_inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed);
|
||||
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) {
|
||||
_movement.copy(this.velocity).multiplyScalar(delta);
|
||||
this.tryMove(_movement);
|
||||
}
|
||||
// Sync the mesh to the body. The body origin is the sphere centre, so the
|
||||
// mesh (origin at the feet) is dropped by the radius.
|
||||
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) {
|
||||
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 {
|
||||
for (const name of ANIM_NAMES[state]) {
|
||||
const action = this.actions.get(name);
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { InputC } from "@24tools/playable_template";
|
||||
import { Object3D } from "three";
|
||||
import { InputC, Physics_internal } from "@24tools/playable_template";
|
||||
import { Box3, Mesh, Object3D, Vector3 } from "three";
|
||||
import { Body, Box, Vec3 } from "cannon-es";
|
||||
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
|
||||
|
||||
|
||||
export class TestSceneC {
|
||||
static mapObject: 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() {
|
||||
this.loadScene();
|
||||
this.loadCharacter();
|
||||
@@ -17,15 +33,161 @@ export class TestSceneC {
|
||||
|
||||
private static loadScene() {
|
||||
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);
|
||||
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() {
|
||||
this.characterObject = ThreeC.getObject("character");
|
||||
this.configureCharacterLoadout();
|
||||
ThreeC.setShadowsStateForChildren(this.characterObject, true, true);
|
||||
this.characterObject.position.set(0, 0.1, -8);
|
||||
this.characterObject.scale.setScalar(1);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,23 +46,15 @@ export class ThreeC extends ThreeC_internal {
|
||||
let dirLight = this.defaultDirectionalLight;
|
||||
|
||||
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.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(this.defaultAmbientLight);
|
||||
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
@@ -16,9 +16,9 @@ window.setupConfig = async function (config) {
|
||||
redirectOptions: {},
|
||||
ticker: Template3d.ticker,
|
||||
debug: {
|
||||
physics: false,
|
||||
physics: true,
|
||||
// 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({
|
||||
config: config || formConfigForPlayable(formConfigUI({
|
||||
|
||||
@@ -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.collidables = [TestSceneC.mapObject];
|
||||
PlayerC.prepareCollidables();
|
||||
|
||||
FollowCameraC.init(TestSceneC.characterObject);
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ export const beforeResourcesLoadedCb = () => {
|
||||
|
||||
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]) => {
|
||||
if (category === "global") {
|
||||
switch (variable) {
|
||||
|
||||
Reference in New Issue
Block a user