Skip to content

Commit c23d3ce

Browse files
committed
3rd Person Controller
1 parent ca16d1d commit c23d3ce

8 files changed

Lines changed: 659 additions & 148 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<!DOCTYPE html><html lang="en"><head><title></title></head>
2+
<style>canvas{ display:block; } body, html { padding:0px; margin:0px; width:100%; height:100%; }</style>
3+
<body><script src="../../../import-map.js"></script><script type="module">
4+
// #region IMPORTS
5+
import useThreeWebGL2, { THREE, useDarkScene, useVisualDebug } from '@lib/useThreeWebGL2.js';
6+
import * as Util from '@lib/util.js';
7+
// import { GLTFLoader } from 'three/GLTFLoader.js';
8+
9+
// import HotKeys from '@lib/misc/HotKeys.js';
10+
import KeyboardInput from '@lib/input/KeyboardInput.js';
11+
import Cursor3DMaterial from '@lib/shader/Cursor3DMaterial.js';
12+
13+
import Vec3 from '@lib/maths/Vec3.js';
14+
import Quat from '@lib/maths/Quat.js';
15+
import Radian from '@lib/maths/Radian.js';
16+
// import FSemiImplicitEuler from '@lib/maths/springs/FSemiImplicitEuler.js';
17+
import FImplicitEuler from '@lib/maths/springs/FImplicitEuler.js';
18+
19+
import { Pane } from '@tp/tweakpane/tweakpane-4.0.4.min.js';
20+
// #endregion
21+
22+
// #region MAIN
23+
let App = useDarkScene( useThreeWebGL2() );
24+
let Debug = {};
25+
let Ref = {};
26+
27+
window.addEventListener( 'load', async ()=>{
28+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
29+
App.sphericalLook( 45, 20, 7, [0,0,0] );
30+
Debug = await useVisualDebug( App );
31+
32+
Ref.ki = new KeyboardInput();
33+
// Ref.hk = new HotKeys().reg( 'x', ()=>{
34+
// Debug.reset();
35+
// Ref.ctrl.update( 0.01, App.camera );
36+
// });
37+
38+
Ref.cursor = Cursor3DMaterial.createMesh( {factor:0} );
39+
App.scene.add( Ref.cursor );
40+
41+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
42+
// const url = '../../../res';
43+
// const dl = await Promise.all([ //allSettled
44+
// loadChar( {path:`${url}/models/mannequin.gltf`} ),
45+
// ]);
46+
47+
// // ---------------------------
48+
// const { tf, skel } = dl[0];
49+
// App.scene.add( tf.scene );
50+
//
51+
//
52+
Ref.ctrl = new Controller();
53+
Ref.ctrl.model = Ref.cursor;
54+
55+
Ref.ctrl.update( 0.01, App.camera, {x:0, y:0} );
56+
57+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
58+
App.createRenderLoop( onPreRender ).start();
59+
appendGithubLink( false );
60+
buildUI();
61+
});
62+
63+
function onPreRender( dt, et ){
64+
Debug.reset();
65+
Ref.ctrl.update( dt, App.camera, Ref.ki.getWASD() );
66+
}
67+
68+
async function buildUI(){
69+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
70+
const p = new Pane( );
71+
const Data = {
72+
Move: 'Use WASD Keys'
73+
};
74+
75+
const f = p.addFolder({ title: 'Instructions', expanded: true });
76+
f.addBinding( Data, 'Move', { readonly: true, });
77+
78+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
79+
Ref.pane = p;
80+
}
81+
// #endregion
82+
83+
// #region LOADING
84+
async function loadChar( props={} ){
85+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
86+
// LOAD
87+
const opt = { skel:true, matswap:null, onSkinned: null, tpose:false, ...props };
88+
const tf = await new GLTFLoader().loadAsync( opt.path );
89+
90+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
91+
let skel = null
92+
let mat = null;
93+
for( const m of Util.traverseFind( tf.scene, o=> (o.type === 'SkinnedMesh') ) ){
94+
// ------------------------------------
95+
switch( typeof opt.matswop ){
96+
case 'string':
97+
switch( opt.matswop ){
98+
case 'toon': m.material = new THREE.MeshToonMaterial( { map: m.material.map, normalMap: m.material.normalMap } ); break;
99+
}
100+
break;
101+
}
102+
103+
// ------------------------------------
104+
if( opt.onSkinned ) opt.onSkinned( m );
105+
106+
// ------------------------------------
107+
if( !skel ) skel = m.skeleton; // First skeleton
108+
}
109+
110+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
111+
return { tf, skel };
112+
}
113+
// #endregion
114+
115+
class Controller{
116+
// #region MAIN
117+
model = null; // Root of model to apply rotation & translation
118+
maxSpeed = 5; // Make Speed of movement
119+
120+
vel = new FImplicitEuler( { osc:1 } ); // Normalized velocity
121+
dir = new FImplicitEuler( { osc:2 } ); // Radian angle of rotation
122+
123+
fwd = new Vec3(); // View Forward Direction
124+
rit = new Vec3(); // View Right Direction
125+
look = new Vec3(); // Character Look Direction
126+
constructor(){}
127+
// #endregion
128+
129+
// #region CALC
130+
updateCharDir( ip ){
131+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
132+
// No Input, slow down to a stop
133+
if( ip.x === 0 && ip.y === 0 ){
134+
this.vel.target = 0;
135+
return;
136+
}
137+
138+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
139+
// Compute rotation angle & look direction from INPUT
140+
const fwd = new Vec3().fromScale( this.fwd, ip.y );
141+
const rit = new Vec3().fromScale( this.rit, ip.x );
142+
this.look.fromAdd( fwd, rit ).norm();
143+
144+
// Compute angle of the look direction from world forward
145+
let rad = Vec3.angle( [0,0,1], this.look );
146+
if( Vec3.dot( [1,0,0], this.look ) < 0 ) rad = -rad;
147+
148+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
149+
// Fix angle to ignore boundary for smooth shortest path rotation
150+
this.dir.target = Radian.continuousTarget( this.dir.value, rad );
151+
this.vel.target = 1; // Speed up
152+
}
153+
154+
updateViewAxis( camera ){
155+
// Compute the XZ plane direction in relation to camera view
156+
const q = new Quat().copyObj( camera.quaternion );
157+
this.fwd.fromQuat( q, [0,0,1] ).negate().sy(0).norm();
158+
this.rit.fromCross( this.fwd, [0,1,0] ).norm();
159+
160+
Debug.ln.add( [0,0,0], this.fwd, 0x00ffff );
161+
Debug.ln.add( [0,0,0], this.rit, 0xffff00 );
162+
}
163+
// #endregion
164+
165+
// #region RENDER LOOP
166+
update( dt, camera, ip ){
167+
this.updateViewAxis( camera ); // Compute View Directions
168+
this.updateCharDir( ip ); // Look direction for rotation & translation
169+
170+
this.dir.update( dt ); // Run Float Spring
171+
this.vel.update( dt );
172+
173+
// Make model face same direction as camera
174+
const rot = new Quat().fromAxisAngle( [0,1,0], this.dir.value );
175+
this.model.quaternion.fromArray( rot );
176+
177+
// 4Debugging
178+
const dir = new Vec3().fromQuat( rot, [0,0,1] ).norm();
179+
Debug.ln.add( [0,0,0], dir, 0xff00ff );
180+
Debug.ln.add( [0,0,0], this.look, 0xffffff );
181+
182+
// Move modal toward the direction it is looking
183+
if( ! this.vel.isDone || this.vel.target > 0 ){
184+
const move = new Vec3().fromScale( this.look, this.maxSpeed * dt * this.vel.value );
185+
this.model.position.x += move[0];
186+
this.model.position.z += move[2];
187+
}
188+
}
189+
// #endregion
190+
}
191+
192+
</script></body></html>

lib/input/KeyboardInput.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export default class KeyboardInput{
2+
// #region MAIN
3+
keys = new Map();
4+
constructor(){
5+
document.body.addEventListener( 'keydown', this.onKeyDown );
6+
document.body.addEventListener( 'keyup', this.onKeyUp );
7+
}
8+
// #endregion
9+
10+
// #region METHODS
11+
isDown( key ){ return (this.keys.get( key ) === true); }
12+
13+
getArrowState(){
14+
const up = ( this.keys.get( 'ArrowUp' ) === true );
15+
const left = ( this.keys.get( 'ArrowLeft' ) === true );
16+
const down = ( this.keys.get( 'ArrowDown' ) === true );
17+
const right = ( this.keys.get( 'ArrowRight' ) === true );
18+
return {
19+
up, left, down, right,
20+
x : left? -1 : right? 1 : 0,
21+
y : down? -1 : up ? 1 : 0,
22+
};
23+
}
24+
25+
getWASD(){
26+
const up = ( this.keys.get( 'w' ) === true )? 1 : 0;
27+
const left = ( this.keys.get( 'a' ) === true )? -1 : 0;
28+
const down = ( this.keys.get( 's' ) === true )? -1 : 0;
29+
const right = ( this.keys.get( 'd' ) === true )? 1 : 0;
30+
return { x: right + left, y: down + up };
31+
}
32+
// #endregion
33+
34+
// #region EVENTSwwwwwwwwwww
35+
onKeyDown = ( e )=>{
36+
// console.log( 'down', e.key );
37+
this.keys.set( e.key.toLowerCase(), true );
38+
};
39+
40+
onKeyUp = ( e )=>{
41+
//console.log( 'up', e );
42+
this.keys.set( e.key.toLowerCase(), false );
43+
};
44+
// #endregion
45+
}

lib/maths/Radian.js

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,35 @@ const TAU = Math.PI * 2;
22

33
export default class Radian{
44

5+
/** Convert radian to degrees */
56
static deg( a ){ return a * 180 / Math.PI; }
67

8+
/** Remap -PI : PI to 0 : TAU */
9+
static tau( a ){
10+
const n = this.norm(a);
11+
return n < 0 ? n + TAU : n;
12+
}
13+
14+
/** Wrap around into the -PI to PI Range */
715
static norm( a ){
816
const x = a % TAU;
917
if( x > Math.PI ) return x - TAU;
1018
if( x < -Math.PI ) return x + TAU;
1119
return x;
1220
}
1321

14-
static isBetween( dMin, dMax, d ){
15-
const nMin = this.norm( dMin );
16-
const nMax = this.norm( dMax );
17-
const nD = this.norm( d );
18-
19-
if( nMin < nMax ) return ( nD >= nMin ) && ( nD <= nMax );
20-
21-
// Crosses -180/180 boundary like 170 to -170
22-
return ( nD >= nMin && nD <= Math.PI ) ||
23-
( nD >= -Math.PI && nD <= nMax );
24-
}
25-
26-
// Total Arc Angle & Starting Offset angle to visualize min & max
22+
/** Total Arc Angle & Starting Offset angle to visualize min & max */
2723
static arcAndOffset( dMin, dMax ){ // : [ arc, offset ]
2824
const nMin = this.norm( dMin );
2925
const nMax = this.norm( dMax );
30-
26+
3127
if( nMin <= nMax ) return [ ( nMax - nMin ), nMin ];
32-
28+
3329
// Crosses -180/180 boundary like 170 to -170
3430
return [ (( Math.PI - nMin ) + ( nMax - (-Math.PI) )) , nMin ];
3531
}
3632

37-
// Helper function to find the shortest angular distance
38-
static angleDist(a1, a2) {
39-
const diff = Math.abs( this.norm(a1) - this.norm(a2) );
40-
return Math.min(diff, TAU - diff);
41-
}
42-
33+
/** Clamp value between the range using -PI to PI as its base */
4334
static clamp( dMin, dMax, d ){
4435
const nMin = this.norm( dMin );
4536
const nMax = this.norm( dMax );
@@ -54,4 +45,88 @@ export default class Radian{
5445
return distToMin < distToMax ? nMin : nMax;
5546
}
5647

57-
}
48+
/** Transition between two angles. Can also do shortest path */
49+
static lerp( a, b, t, shortestPath = true ){
50+
const start = this.norm(a);
51+
let end = this.norm(b);
52+
53+
if( shortestPath ){
54+
const diff = end - start;
55+
56+
// If the difference is greater than PI, it's shorter to go the other way
57+
if( diff > Math.PI ) end -= TAU;
58+
else if( diff < -Math.PI ) end += TAU;
59+
}
60+
61+
return this.norm( start * (1 - t) + end * t );
62+
}
63+
64+
/** Change angle toward target over time
65+
time : Approximately the time it will take to reach the target
66+
maxSpeed : Optional limit to the rotation speed */
67+
static smoothDamp( cur, tar, vel, time, dt, maxSpeed = Infinity ){ // [ value, newVel ]
68+
let diff = this.norm( tar - cur );
69+
70+
// Clamp the step to maxSpeed
71+
const maxStep = maxSpeed * time;
72+
diff = Math.max( -maxStep, Math.min( maxStep, diff ) );
73+
74+
const omega = 2 / time;
75+
const x = omega * dt;
76+
const exp = 1 / ( 1 + x + 0.48 * x * x + 0.235 * x * x * x );
77+
const temp = ( vel + omega * diff ) * dt;
78+
79+
vel = ( vel - omega * temp ) * exp;
80+
const rtn = ( current + diff ) - ( diff + temp ) * exp;
81+
82+
return [ this.norm( rtn ), vel ];
83+
}
84+
85+
// #region ANGLE CALC
86+
/** Helper function to find the shortest angular distance, does not go over PI */
87+
static angleDist( a1, a2 ){
88+
const diff = Math.abs( this.norm(a1) - this.norm(a2) );
89+
return Math.min( diff, TAU - diff );
90+
}
91+
92+
/** Fix target angle so it doesn't cross the abs(180) boundary */
93+
static shortestTarget( a, b ){
94+
const start = this.norm( a );
95+
let end = this.norm( b );
96+
const diff = end - start;
97+
98+
if( diff > Math.PI ) end -= TAU;
99+
else if( diff < -Math.PI ) end += TAU;
100+
101+
return end;
102+
}
103+
104+
/** Move toward target from initial. Angle will not be clamped into a range */
105+
static continuousTarget( a, b ){ return a + this.norm( b - a ); }
106+
107+
/** Calculates the angle of a 2D vector from origin, return [-PI, PI] */
108+
static angle2D( x, y ){ return Math.atan2(y, x); }
109+
110+
/** Angle needed to rotate from vector A to B */
111+
static angleDiff2D( ax, ay, bx, by ){ return Math.atan2( by - ay, bx - ax ); }
112+
// #endregion
113+
114+
// #region Checks
115+
/** Checks if an angle is between two angles ( -PI to PI Range ) */
116+
static isBetween( dMin, dMax, d ){
117+
const nMin = this.norm( dMin );
118+
const nMax = this.norm( dMax );
119+
const nD = this.norm( d );
120+
121+
if( nMin < nMax ) return ( nD >= nMin ) && ( nD <= nMax );
122+
123+
// Crosses -180/180 boundary like 170 to -170
124+
return ( nD >= nMin && nD <= Math.PI ) ||
125+
( nD >= -Math.PI && nD <= nMax );
126+
}
127+
128+
/** Checks if the 180/-180 boundary is being crossed */
129+
static isCrossingPI( a, b ){ return Math.abs( this.norm( a ) - this.norm( b ) ) > Math.PI; }
130+
// #endregion
131+
132+
}

0 commit comments

Comments
 (0)