feat: add joystick movement with character animations, add smooth camera behaviour with look-ahead effect
This commit is contained in:
@@ -37,6 +37,10 @@ export class CameraDebugUI {
|
|||||||
.name("Scale [0.1–3]")
|
.name("Scale [0.1–3]")
|
||||||
.onChange((v: number) => TestSceneC.characterObject.scale.setScalar(v));
|
.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 orbitControls: OrbitControls | null = null;
|
||||||
let orbitDelegate: Delegate<number> | null = null;
|
let orbitDelegate: Delegate<number> | null = null;
|
||||||
let orbitOverlay: HTMLDivElement | null = null;
|
let orbitOverlay: HTMLDivElement | null = null;
|
||||||
|
|||||||
@@ -1,49 +1,54 @@
|
|||||||
import { CameraC_internal, UpdateController } from "@24tools/playable_template";
|
import { CameraC_internal, UpdateController } from "@24tools/playable_template";
|
||||||
import { Object3D, Vector3 } from "three";
|
import { Object3D, Vector3 } from "three";
|
||||||
|
|
||||||
const _targetWorldPos = new Vector3();
|
const _targetWorldPos = new Vector3();
|
||||||
|
const _lookAheadTarget = new Vector3();
|
||||||
|
const _desired = new Vector3();
|
||||||
|
|
||||||
export class FollowCameraC {
|
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 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 target: Object3D | null = null;
|
||||||
|
private static _lookAheadCurrent = new Vector3();
|
||||||
|
|
||||||
static init(target: Object3D) {
|
static init(target: Object3D) {
|
||||||
this.target = target;
|
this.target = target;
|
||||||
|
|
||||||
const camera = CameraC_internal.camera!;
|
const camera = CameraC_internal.camera!;
|
||||||
target.getWorldPosition(_targetWorldPos);
|
target.getWorldPosition(_targetWorldPos);
|
||||||
|
// Camera config is authored for a character at origin, so the camera's
|
||||||
// Offset = config camera position minus character world position.
|
// world position IS the intended relative offset from any character position.
|
||||||
// Rotation is already set by config — do not modify.
|
this.offset.copy(camera.position);
|
||||||
this.offset.copy(camera.position).sub(_targetWorldPos);
|
// Immediately place camera relative to the actual character position
|
||||||
|
// (not config origin) so the character is visible from frame one.
|
||||||
UpdateController.Instance.onUpdate.addDelegate((delta) => {
|
camera.position.copy(_targetWorldPos).add(this.offset);
|
||||||
this.update(delta);
|
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called from resizeCb after CameraC.setCamera().
|
// Re-syncs base offset after a config-driven camera move (e.g. resize).
|
||||||
// Re-syncs offset from the new config (portrait/landscape may differ),
|
// CameraC.setCamera() already moved the camera to the config position,
|
||||||
// camera position is already correct after setCamera so no snap needed.
|
// which equals the desired relative offset — just re-read it.
|
||||||
static syncAndSnap() {
|
static syncAndSnap() {
|
||||||
const camera = CameraC_internal.camera;
|
const camera = CameraC_internal.camera;
|
||||||
if (!this.target || !camera) return;
|
if (!this.target || !camera) return;
|
||||||
|
this.offset.copy(camera.position);
|
||||||
this.target.getWorldPosition(_targetWorldPos);
|
this.target.getWorldPosition(_targetWorldPos);
|
||||||
// CameraC.setCamera() placed camera.position at the config world position.
|
camera.position.copy(_targetWorldPos).add(this.offset);
|
||||||
// Recompute offset as the difference between that position and current character position.
|
this._lookAheadCurrent.set(0, 0, 0);
|
||||||
this.offset.copy(camera.position).sub(_targetWorldPos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instantly moves camera to character + offset without lerp.
|
|
||||||
static snapToTarget() {
|
static snapToTarget() {
|
||||||
const camera = CameraC_internal.camera;
|
const camera = CameraC_internal.camera;
|
||||||
if (!this.target || !camera) return;
|
if (!this.target || !camera) return;
|
||||||
this.target.getWorldPosition(_targetWorldPos);
|
this.target.getWorldPosition(_targetWorldPos);
|
||||||
camera.position.copy(_targetWorldPos.clone().add(this.offset));
|
camera.position.copy(_targetWorldPos).add(this.offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static update(delta: number) {
|
private static update(delta: number) {
|
||||||
@@ -53,8 +58,19 @@ export class FollowCameraC {
|
|||||||
|
|
||||||
this.target.getWorldPosition(_targetWorldPos);
|
this.target.getWorldPosition(_targetWorldPos);
|
||||||
|
|
||||||
// Position only. Rotation is fixed by config.
|
// Look-ahead: smoothly track the player's forward direction.
|
||||||
const desired = _targetWorldPos.clone().add(this.offset);
|
// target.rotation.y is the mesh Y-axis rotation set by PlayerC.
|
||||||
camera.position.lerp(desired, Math.min(1, this.lerpSpeed * delta));
|
// 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export class TestSceneC {
|
|||||||
private static loadCharacter() {
|
private static loadCharacter() {
|
||||||
this.characterObject = ThreeC.getObject("character");
|
this.characterObject = ThreeC.getObject("character");
|
||||||
ThreeC.setShadowsStateForChildren(this.characterObject, true, true);
|
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);
|
this.characterObject.scale.setScalar(1);
|
||||||
ThreeC.addToScene(this.characterObject);
|
ThreeC.addToScene(this.characterObject);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -80,7 +80,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#interactive {
|
#interactive {
|
||||||
z-index: 10;
|
z-index: 1001;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -121,12 +121,13 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#joystick_zone {
|
#joystick_zone {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 900;
|
z-index: 1001;
|
||||||
|
touch-action: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (orientation: landscape) {
|
@media (orientation: landscape) {
|
||||||
|
|||||||
+3
-1
@@ -30,9 +30,11 @@
|
|||||||
<div id="interactive">
|
<div id="interactive">
|
||||||
<h3 class="interactive-text">Redirected to store...</h3>
|
<h3 class="interactive-text">Redirected to store...</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="joystick_zone"></div>
|
||||||
|
|
||||||
<div id="constructor">
|
<div id="constructor">
|
||||||
<div id="ui"></div>
|
<div id="ui">
|
||||||
|
</div>
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
</div>
|
</div>
|
||||||
<script id="dev-start">
|
<script id="dev-start">
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { TestSceneC } from "../controllers/TestSceneC";
|
import { TestSceneC } from "../controllers/TestSceneC";
|
||||||
import { FollowCameraC } from "../controllers/FollowCameraC";
|
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 () => {
|
export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
|
||||||
TestSceneC.init();
|
TestSceneC.init();
|
||||||
SoundC.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);
|
FollowCameraC.init(TestSceneC.characterObject);
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CameraC_internal,
|
CameraC_internal,
|
||||||
CameraType,
|
CameraType,
|
||||||
// JoystickC,
|
|
||||||
Physics_internal,
|
Physics_internal,
|
||||||
Template,
|
Template,
|
||||||
Template3d,
|
Template3d,
|
||||||
@@ -24,23 +23,7 @@ export const beforeResourcesLoadedCb = () => {
|
|||||||
ThreeC.createBaseLights();
|
ThreeC.createBaseLights();
|
||||||
ThreeC.setupDirectionalLight();
|
ThreeC.setupDirectionalLight();
|
||||||
|
|
||||||
let physicsWorld = Physics_internal.init(new Vec3(0, -9.81, 0));
|
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);
|
|
||||||
// })
|
|
||||||
|
|
||||||
Template.updateVariableConfig.addDelegate(([category, variable, value]) => {
|
Template.updateVariableConfig.addDelegate(([category, variable, value]) => {
|
||||||
if (category === "global") {
|
if (category === "global") {
|
||||||
|
|||||||
Reference in New Issue
Block a user