feat: add joystick movement with character animations, add smooth camera behaviour with look-ahead effect

This commit is contained in:
Mykyta Slobodianiuk
2026-06-03 13:07:26 +03:00
parent 0003919f4c
commit 0b469df12c
8 changed files with 272 additions and 48 deletions
+4
View File
@@ -37,6 +37,10 @@ export class CameraDebugUI {
.name("Scale [0.13]")
.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<number> | null = null;
let orbitOverlay: HTMLDivElement | null = null;
+40 -24
View File
@@ -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));
}
}
+198
View File
@@ -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<string, string[]> = {
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<string, AnimationAction>();
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;
}
}
+1 -1
View File
@@ -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);
}
+4 -3
View File
@@ -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) {
+3 -1
View File
@@ -30,9 +30,11 @@
<div id="interactive">
<h3 class="interactive-text">Redirected to store...</h3>
</div>
<div id="joystick_zone"></div>
<div id="constructor">
<div id="ui"></div>
<div id="ui">
</div>
<div id="editor"></div>
</div>
<script id="dev-start">
+21 -1
View File
@@ -1,11 +1,31 @@
import { TestSceneC } from "../controllers/TestSceneC";
import { FollowCameraC } from "../controllers/FollowCameraC";
import { SoundC, Template } from "@24tools/playable_template";
import { PlayerC } from "../controllers/PlayerC";
import { JoystickC, SoundC, Template } from "@24tools/playable_template";
export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
TestSceneC.init();
SoundC.init();
const joystickZone = document.getElementById("joystick_zone") as HTMLDivElement;
if (!joystickZone) {
console.warn("[JoystickC] #joystick_zone element not found in DOM");
} else {
JoystickC.init({
zone: joystickZone,
fadeTime: 0,
mode: "dynamic",
restJoystick: true,
catchDistance: 1,
restOpacity: 0,
follow: false,
});
}
PlayerC.init(TestSceneC.characterObject);
PlayerC.collidables = [TestSceneC.mapObject];
PlayerC.prepareCollidables();
FollowCameraC.init(TestSceneC.characterObject);
if (import.meta.env.DEV) {
+1 -18
View File
@@ -1,7 +1,6 @@
import {
CameraC_internal,
CameraType,
// JoystickC,
Physics_internal,
Template,
Template3d,
@@ -24,23 +23,7 @@ export const beforeResourcesLoadedCb = () => {
ThreeC.createBaseLights();
ThreeC.setupDirectionalLight();
let physicsWorld = Physics_internal.init(new Vec3(0, -9.81, 0));
// example of using joystick. Uncomment if you need joystick
// JoystickC.init({
// zone: document.getElementById("joystick_zone") as HTMLDivElement,
// fadeTime: 0,
// mode: "dynamic",
// restJoystick: true,
// catchDistance: 1,
// restOpacity: 0,
// follow: false,
// });
// JoystickC.onJoysticMove.addDelegate(({event, data}) => {
// console.log('onJoysticMove', event, data);
// })
Physics_internal.init(new Vec3(0, -9.81, 0));
Template.updateVariableConfig.addDelegate(([category, variable, value]) => {
if (category === "global") {