diff --git a/src/controllers/CameraDebugUI.ts b/src/controllers/CameraDebugUI.ts index 0dffd95..821a6cb 100644 --- a/src/controllers/CameraDebugUI.ts +++ b/src/controllers/CameraDebugUI.ts @@ -37,6 +37,10 @@ export class CameraDebugUI { .name("Scale [0.1–3]") .onChange((v: number) => TestSceneC.characterObject.scale.setScalar(v)); + charFolder.add(TestSceneC.characterObject.position, "x", -50, 50, 0.1).name("Pos X"); + charFolder.add(TestSceneC.characterObject.position, "y", -10, 10, 0.1).name("Pos Y"); + charFolder.add(TestSceneC.characterObject.position, "z", -50, 50, 0.1).name("Pos Z"); + let orbitControls: OrbitControls | null = null; let orbitDelegate: Delegate | null = null; let orbitOverlay: HTMLDivElement | null = null; diff --git a/src/controllers/FollowCameraC.ts b/src/controllers/FollowCameraC.ts index 80c86a3..f4b8b10 100644 --- a/src/controllers/FollowCameraC.ts +++ b/src/controllers/FollowCameraC.ts @@ -1,49 +1,54 @@ import { CameraC_internal, UpdateController } from "@24tools/playable_template"; import { Object3D, Vector3 } from "three"; -const _targetWorldPos = new Vector3(); +const _targetWorldPos = new Vector3(); +const _lookAheadTarget = new Vector3(); +const _desired = new Vector3(); export class FollowCameraC { - // Populated in init() from the camera position set by CameraC config - static offset = new Vector3(); + static offset = new Vector3(); static lerpSpeed = 6; - static paused = false; + static paused = false; + + // Camera shifts toward where the player is facing. + // After the player stops the lerp continues to drift — creating the + // "camera settles into the facing direction" feel. + static lookAheadStrength = 1.5; // world units to shift ahead + static lookAheadLerpSpeed = 2; // low value → slow drift after stop private static target: Object3D | null = null; + private static _lookAheadCurrent = new Vector3(); static init(target: Object3D) { this.target = target; - const camera = CameraC_internal.camera!; target.getWorldPosition(_targetWorldPos); - - // Offset = config camera position minus character world position. - // Rotation is already set by config — do not modify. - this.offset.copy(camera.position).sub(_targetWorldPos); - - UpdateController.Instance.onUpdate.addDelegate((delta) => { - this.update(delta); - }); + // Camera config is authored for a character at origin, so the camera's + // world position IS the intended relative offset from any character position. + this.offset.copy(camera.position); + // Immediately place camera relative to the actual character position + // (not config origin) so the character is visible from frame one. + camera.position.copy(_targetWorldPos).add(this.offset); + UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); } - // Called from resizeCb after CameraC.setCamera(). - // Re-syncs offset from the new config (portrait/landscape may differ), - // camera position is already correct after setCamera so no snap needed. + // Re-syncs base offset after a config-driven camera move (e.g. resize). + // CameraC.setCamera() already moved the camera to the config position, + // which equals the desired relative offset — just re-read it. static syncAndSnap() { const camera = CameraC_internal.camera; if (!this.target || !camera) return; + this.offset.copy(camera.position); this.target.getWorldPosition(_targetWorldPos); - // CameraC.setCamera() placed camera.position at the config world position. - // Recompute offset as the difference between that position and current character position. - this.offset.copy(camera.position).sub(_targetWorldPos); + camera.position.copy(_targetWorldPos).add(this.offset); + this._lookAheadCurrent.set(0, 0, 0); } - // Instantly moves camera to character + offset without lerp. static snapToTarget() { const camera = CameraC_internal.camera; if (!this.target || !camera) return; this.target.getWorldPosition(_targetWorldPos); - camera.position.copy(_targetWorldPos.clone().add(this.offset)); + camera.position.copy(_targetWorldPos).add(this.offset); } private static update(delta: number) { @@ -53,8 +58,19 @@ export class FollowCameraC { this.target.getWorldPosition(_targetWorldPos); - // Position only. Rotation is fixed by config. - const desired = _targetWorldPos.clone().add(this.offset); - camera.position.lerp(desired, Math.min(1, this.lerpSpeed * delta)); + // Look-ahead: smoothly track the player's forward direction. + // target.rotation.y is the mesh Y-axis rotation set by PlayerC. + // When the player stops, the lerp keeps drifting toward the last + // facing direction — the "settle after stop" effect comes for free. + const ry = this.target.rotation.y; + _lookAheadTarget.set( + Math.sin(ry) * this.lookAheadStrength, + 0, + Math.cos(ry) * this.lookAheadStrength, + ); + this._lookAheadCurrent.lerp(_lookAheadTarget, Math.min(1, this.lookAheadLerpSpeed * delta)); + + _desired.copy(_targetWorldPos).add(this.offset).add(this._lookAheadCurrent); + camera.position.lerp(_desired, Math.min(1, this.lerpSpeed * delta)); } } diff --git a/src/controllers/PlayerC.ts b/src/controllers/PlayerC.ts new file mode 100644 index 0000000..1b1e069 --- /dev/null +++ b/src/controllers/PlayerC.ts @@ -0,0 +1,198 @@ +import { CameraC_internal, JoystickC, ThreeC_internal, UpdateController } from "@24tools/playable_template"; +import { AnimationAction, AnimationMixer, DoubleSide, Mesh, Object3D, Raycaster, Vector3 } from "three"; + +const ANIM_NAMES: Record = { + idle: ["idle", "Idle", "IDLE", "stand", "Stand"], + walk: ["walk", "Walk", "WALK", "walking", "Walking"], + run: ["run", "Run", "RUN", "running", "Running", "sprint", "Sprint"], +}; + +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 { + static maxSpeed = 4; + static acceleration = 10; + static rotateSpeed = 8; + + static collidables: Object3D[] = []; + + private static mesh: Object3D; + private static mixer: AnimationMixer; + private static actions = new Map(); + private static state = MoveState.Idle; + private static currentAction: AnimationAction | null = null; + + static velocity = new Vector3(); + private static inputDir = new Vector3(); + + private static _camForward = new Vector3(); + private static _camRight = new Vector3(); + private static _worldUp = new Vector3(0, 1, 0); + + static init(mesh: Object3D) { + this.mesh = mesh; + 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)"); + } + + // ── Private ──────────────────────────────────────────────────────────────── + + private static setupAnimations() { + const gltf = ThreeC_internal.getMesh("character"); + this.mixer = new AnimationMixer(this.mesh); + ThreeC_internal.addAnimMixer(this.mixer); + if (gltf.animations?.length) { + console.log("[PlayerC] Available animations:", gltf.animations.map(a => a.name)); + gltf.animations.forEach(clip => { + this.actions.set(clip.name, this.mixer.clipAction(clip)); + }); + } + // Play idle directly — transitionTo guards same-state calls so it would no-op here + const idleAction = this.findAction(MoveState.Idle); + if (idleAction) { + idleAction.reset().play(); + this.currentAction = idleAction; + } + } + + private static setupJoystick() { + JoystickC.onJoysticMove.addDelegate(({ event }) => { + // @ts-ignore + this.applyJoystickInput(event.data.vector.x, event.data.vector.y); + }); + JoystickC.onJoysticEnd.addDelegate(() => { + this.inputDir.set(0, 0, 0); + }); + } + + private static applyJoystickInput(jx: number, jy: number) { + const camera = CameraC_internal.camera; + if (!camera) return; + camera.getWorldDirection(this._camForward); + this._camForward.y = 0; + this._camForward.normalize(); + this._camRight.crossVectors(this._camForward, this._worldUp).normalize(); + this.inputDir + .copy(this._camForward).multiplyScalar(jy) + .addScaledVector(this._camRight, jx); + + // Rescale input magnitude with a curve. + // exponent < 1 → light touch feels faster (e.g. 0.5 = sqrt) + // exponent > 1 → more precision at low input, snappy at full push + const CURVE = 0.5; + const len = this.inputDir.length(); + if (len > 0.01) { + this.inputDir.multiplyScalar(Math.pow(len, CURVE - 1)); + // equivalent to: newLen = len^CURVE, then normalize and scale + } + + + if (this.inputDir.lengthSq() > 1) this.inputDir.normalize(); + } + + private static update(delta: number) { + _inputTarget.copy(this.inputDir).multiplyScalar(this.maxSpeed); + this.velocity.lerp(_inputTarget, Math.min(1, this.acceleration * delta)); + + const speed = this.velocity.length(); + + if (speed > 0.01) { + _movement.copy(this.velocity).multiplyScalar(delta); + this.tryMove(_movement); + } + + if (speed > 0.05) { + const targetAngle = Math.atan2(this.velocity.x, this.velocity.z); + const diff = ((targetAngle - this.mesh.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI; + this.mesh.rotation.y += diff * Math.min(1, this.rotateSpeed * delta); + } + + // Animation state machine + if (speed < 0.1) this.transitionTo(MoveState.Idle); + else if (speed < this.maxSpeed * 0.6) this.transitionTo(MoveState.Walk); + else this.transitionTo(MoveState.Run); + + // Proportional animation speed, normalized per-state so each state + // goes from ~0.5x at entry to 1.0x at the top of its range. + // This avoids the near-frozen look at low joystick input. + if (this.currentAction && this.state !== MoveState.Idle) { + const walkTop = this.maxSpeed * 0.6; + const t = this.state === MoveState.Walk + ? speed / walkTop // 0 → 1 within walk range + : (speed - walkTop) / (this.maxSpeed - walkTop); // 0 → 1 within run range + this.currentAction.setEffectiveTimeScale(0.4 + 0.3 * Math.min(1, t)); + } + } + + 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); + if (action) return action; + } + for (const [clipName, action] of this.actions) { + if (clipName.toLowerCase().includes(state)) return action; + } + return null; + } + + private static transitionTo(next: MoveState, fadeDuration = 0.2) { + if (this.state === next) return; + const action = this.findAction(next); + if (!action) return; + this.currentAction?.fadeOut(fadeDuration); + action.reset().fadeIn(fadeDuration).play(); + this.currentAction = action; + this.state = next; + } +} diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index 45ae996..121c7f8 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -24,7 +24,7 @@ export class TestSceneC { private static loadCharacter() { this.characterObject = ThreeC.getObject("character"); ThreeC.setShadowsStateForChildren(this.characterObject, true, true); - this.characterObject.position.set(0, 0, 0); + this.characterObject.position.set(0, 0.1, -8); this.characterObject.scale.setScalar(1); ThreeC.addToScene(this.characterObject); } diff --git a/src/css/main.css b/src/css/main.css index 8a0f7e4..2c585fa 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -80,7 +80,7 @@ canvas { } #interactive { - z-index: 10; + z-index: 1001; position: fixed; inset: 0; width: 100%; @@ -121,12 +121,13 @@ canvas { } #joystick_zone { - position: absolute; + position: fixed; width: 100%; height: 100%; left: 0; top: 0; - z-index: 900; + z-index: 1001; + touch-action: auto; } @media (orientation: landscape) { diff --git a/src/index.html b/src/index.html index 877f0ef..9f66cfa 100644 --- a/src/index.html +++ b/src/index.html @@ -30,9 +30,11 @@

Redirected to store...

+
-
+
+