diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..1da87c5 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "playable", + "runtimeExecutable": "npm", + "runtimeArgs": ["start"], + "port": 5173 + } + ] +} diff --git a/.gitignore b/.gitignore index 57f1443..8688abc 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,4 @@ export_test/ extra_compress/ preview_dist/ build/ +.claude/ \ No newline at end of file diff --git a/src/controllers/PlayerC.ts b/src/controllers/PlayerC.ts index 1b1e069..47b48d3 100644 --- a/src/controllers/PlayerC.ts +++ b/src/controllers/PlayerC.ts @@ -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 = { idle: ["idle", "Idle", "IDLE", "stand", "Stand"], @@ -10,13 +12,7 @@ const ANIM_NAMES: Record = { 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(); 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); diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index 121c7f8..95589d2 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -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`); + } } } diff --git a/src/controllers/ThreeC.ts b/src/controllers/ThreeC.ts index 4dfd974..ad7f48c 100644 --- a/src/controllers/ThreeC.ts +++ b/src/controllers/ThreeC.ts @@ -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; } } diff --git a/src/index.ts b/src/index.ts index 4eb2600..ef118f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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({ diff --git a/src/templateConfig/afterResourcesLoadedCb.ts b/src/templateConfig/afterResourcesLoadedCb.ts index a7ab5a6..246ba7b 100644 --- a/src/templateConfig/afterResourcesLoadedCb.ts +++ b/src/templateConfig/afterResourcesLoadedCb.ts @@ -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); diff --git a/src/templateConfig/beforeResourcesLoadedCb.ts b/src/templateConfig/beforeResourcesLoadedCb.ts index 77ee1a0..3d9d941 100644 --- a/src/templateConfig/beforeResourcesLoadedCb.ts +++ b/src/templateConfig/beforeResourcesLoadedCb.ts @@ -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) {