Codex CLI commited on
Commit
d6dfcf1
·
1 Parent(s): bf6239d

feat(player): enhance movement mechanics with sliding, jump buffering, and wall bounce features

Browse files
Files changed (5) hide show
  1. src/config.js +22 -1
  2. src/events.js +12 -6
  3. src/hud.js +1 -1
  4. src/main.js +15 -1
  5. src/player.js +152 -43
src/config.js CHANGED
@@ -26,7 +26,28 @@ export const CFG = {
26
  health: 100,
27
  jumpVel: 5,
28
  eyeHeight: 1.8,
29
- crouchEyeHeight: 1.2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  },
31
  flashlight: {
32
  on: true,
 
26
  health: 100,
27
  jumpVel: 5,
28
  eyeHeight: 1.8,
29
+ crouchEyeHeight: 1.2,
30
+ // Apex-like movement tuning
31
+ move: {
32
+ friction: 5.5, // ground friction (~4-6)
33
+ stopSpeed: 6.0, // speed where friction clamps (m/s)
34
+ groundAccel: 11.0, // ground acceleration
35
+ airAccel: 18.0, // air acceleration (allow redirection)
36
+ gravity: 15.0, // matches prior gravity
37
+ jumpSpeed: 5.0, // vertical impulse (mirrors jumpVel)
38
+ airSpeedCap: 10.0, // optional cap on sideways air accel
39
+ // Sliding
40
+ slideFriction: 1.2, // lower than ground friction
41
+ slideAccel: 10.0,
42
+ slideMinSpeed: 6.0, // enter slide when crouch and >= this speed
43
+ slideJumpBoost: 1.0, // forward boost on slide-jump (m/s)
44
+ // Helpers
45
+ jumpBuffer: 0.12, // seconds to buffer jump presses
46
+ coyoteTime: 0.12, // late jump after leaving ground
47
+ // Wall bounce
48
+ wallBounceWindow: 0.10, // seconds after wall contact to accept jump
49
+ wallBounceImpulse: 1.2 // outward push on wall bounce (m/s)
50
+ }
51
  },
52
  flashlight: {
53
  on: true,
src/events.js CHANGED
@@ -15,6 +15,9 @@ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponA
15
  G.controls.lock();
16
  } else if (G.state === 'paused') {
17
  G.controls.lock();
 
 
 
18
  }
19
  });
20
 
@@ -24,6 +27,9 @@ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponA
24
  } else if (G.state === 'paused') {
25
  G.state = 'playing';
26
  overlay.classList.add('hidden');
 
 
 
27
  }
28
  });
29
 
@@ -49,10 +55,9 @@ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponA
49
  }
50
  break;
51
  case 'Space':
52
- if (G.state === 'playing' && G.player.grounded) {
53
- // Jump
54
- G.player.yVel = CFG.player.jumpVel;
55
- G.player.grounded = false;
56
  }
57
  break;
58
  case 'KeyF':
@@ -69,8 +74,6 @@ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponA
69
  case 'KeyR':
70
  if (G.state === 'playing') {
71
  beginReload();
72
- } else if (G.state === 'gameover') {
73
- restartGame();
74
  }
75
  break;
76
  }
@@ -90,6 +93,9 @@ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponA
90
  releaseGrenade();
91
  }
92
  break;
 
 
 
93
  }
94
  });
95
 
 
15
  G.controls.lock();
16
  } else if (G.state === 'paused') {
17
  G.controls.lock();
18
+ } else if (G.state === 'gameover') {
19
+ // Restart on click after game over
20
+ restartGame();
21
  }
22
  });
23
 
 
27
  } else if (G.state === 'paused') {
28
  G.state = 'playing';
29
  overlay.classList.add('hidden');
30
+ } else if (G.state === 'gameover') {
31
+ // Treat lock like a fresh start after game over
32
+ startGame();
33
  }
34
  });
35
 
 
55
  }
56
  break;
57
  case 'Space':
58
+ if (G.state === 'playing') {
59
+ // Use buffered jump handled in player physics
60
+ G.input.jump = true;
 
61
  }
62
  break;
63
  case 'KeyF':
 
74
  case 'KeyR':
75
  if (G.state === 'playing') {
76
  beginReload();
 
 
77
  }
78
  break;
79
  }
 
93
  releaseGrenade();
94
  }
95
  break;
96
+ case 'Space':
97
+ G.input.jump = false;
98
+ break;
99
  }
100
  });
101
 
src/hud.js CHANGED
@@ -92,7 +92,7 @@ export function showOverlay(type) {
92
  <h1>You Died</h1>
93
  <p>Score: ${G.player.score}</p>
94
  <p>Wave: ${G.waves.current}</p>
95
- <p>Press R to Restart</p>
96
  `;
97
  }
98
  }
 
92
  <h1>You Died</h1>
93
  <p>Score: ${G.player.score}</p>
94
  <p>Wave: ${G.waves.current}</p>
95
+ <p>Click to Restart</p>
96
  `;
97
  }
98
  }
src/main.js CHANGED
@@ -65,7 +65,14 @@ function init() {
65
  alive: true,
66
  score: 0,
67
  yVel: 0,
68
- grounded: true
 
 
 
 
 
 
 
69
  };
70
  G.weapon.ammo = CFG.gun.magSize;
71
  G.weapon.reserve = Infinity;
@@ -105,6 +112,13 @@ function startGame() {
105
  G.player.alive = true;
106
  G.player.pos.set(0, getTerrainHeight(0, 0) + (CFG.player.eyeHeight || 1.8), 0);
107
  G.player.vel.set(0, 0, 0);
 
 
 
 
 
 
 
108
  G.camera.position.copy(G.player.pos);
109
  G.damageFlash = 0;
110
  G.healFlash = 0;
 
65
  alive: true,
66
  score: 0,
67
  yVel: 0,
68
+ grounded: true,
69
+ // Apex-like movement state
70
+ sliding: false,
71
+ jumpBuffer: 0,
72
+ coyoteTimer: 0,
73
+ lastWallNormal: new THREE.Vector3(),
74
+ wallContactTimer: 0
75
+ , jumpHeld: false
76
  };
77
  G.weapon.ammo = CFG.gun.magSize;
78
  G.weapon.reserve = Infinity;
 
112
  G.player.alive = true;
113
  G.player.pos.set(0, getTerrainHeight(0, 0) + (CFG.player.eyeHeight || 1.8), 0);
114
  G.player.vel.set(0, 0, 0);
115
+ G.player.yVel = 0;
116
+ G.player.grounded = true;
117
+ G.player.sliding = false;
118
+ G.player.jumpBuffer = 0;
119
+ G.player.coyoteTimer = 0;
120
+ G.player.lastWallNormal.set(0, 0, 0);
121
+ G.player.wallContactTimer = 0;
122
  G.camera.position.copy(G.player.pos);
123
  G.damageFlash = 0;
124
  G.healFlash = 0;
src/player.js CHANGED
@@ -3,82 +3,191 @@ import { CFG } from './config.js';
3
  import { G } from './globals.js';
4
  import { getTerrainHeight, getNearbyTrees } from './world.js';
5
 
6
- const MOVE = new THREE.Vector3();
7
  const FWD = new THREE.Vector3();
8
  const RIGHT = new THREE.Vector3();
9
  const NEXT = new THREE.Vector3();
10
  const PREV = new THREE.Vector3();
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  export function updatePlayer(delta) {
13
  if (!G.player.alive) return;
14
 
15
- // Movement
16
- MOVE.set(0, 0, 0);
17
 
 
18
  G.camera.getWorldDirection(FWD);
19
- FWD.y = 0;
20
- FWD.normalize();
21
 
22
- RIGHT.crossVectors(FWD, G.camera.up);
 
 
 
 
 
 
 
23
 
24
- if (G.input.w) MOVE.add(FWD);
25
- if (G.input.s) MOVE.sub(FWD);
26
- if (G.input.a) MOVE.sub(RIGHT);
27
- if (G.input.d) MOVE.add(RIGHT);
 
28
 
29
- if (MOVE.length() > 0) {
30
- MOVE.normalize();
31
- let speed = G.player.speed * (G.input.sprint ? CFG.player.sprintMult : 1);
32
- if (G.input.crouch) speed *= (CFG.player.crouchMult || 1);
33
- MOVE.multiplyScalar(speed * delta);
 
34
  }
 
35
 
36
- // Apply movement with collision
37
- NEXT.copy(G.player.pos).add(MOVE);
38
 
39
- // Tree collisions (use spatial grid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  const nearTrees = getNearbyTrees(NEXT.x, NEXT.z, 3.5);
41
  for (let i = 0; i < nearTrees.length; i++) {
42
  const tree = nearTrees[i];
43
  const dx = NEXT.x - tree.x;
44
  const dz = NEXT.z - tree.z;
45
- const dist = Math.sqrt(dx * dx + dz * dz);
46
- const minDist = G.player.radius + tree.radius;
47
  if (dist < minDist && dist > 0) {
48
- const pushX = (dx / dist) * (minDist - dist);
49
- const pushZ = (dz / dist) * (minDist - dist);
50
- NEXT.x += pushX;
51
- NEXT.z += pushZ;
 
 
 
 
 
 
52
  }
53
  }
54
 
55
- // Bounds
56
- const halfSize = CFG.forestSize / 2 - G.player.radius;
57
  NEXT.x = Math.max(-halfSize, Math.min(halfSize, NEXT.x));
58
  NEXT.z = Math.max(-halfSize, Math.min(halfSize, NEXT.z));
59
 
60
- // Gravity
61
- G.player.yVel -= 15 * delta; // gravity
62
- NEXT.y += G.player.yVel * delta;
63
-
64
- // Grounded against terrain height
65
  const eye = G.input.crouch ? (CFG.player.crouchEyeHeight || 1.8) : (CFG.player.eyeHeight || 1.8);
66
  const groundEye = getTerrainHeight(NEXT.x, NEXT.z) + eye;
67
  if (NEXT.y <= groundEye) {
68
  NEXT.y = groundEye;
69
- G.player.yVel = 0;
70
- G.player.grounded = true;
71
  } else {
72
- G.player.grounded = false;
73
  }
74
 
75
- // Update velocity estimate (units/sec)
76
- PREV.copy(G.player.pos);
77
- G.player.pos.copy(NEXT);
78
- if (delta > 0) {
79
- G.player.vel.copy(G.player.pos).sub(PREV).multiplyScalar(1 / delta);
80
- // Ignore vertical component for prediction stability
81
- G.player.vel.y = 0;
82
- }
83
- G.camera.position.copy(G.player.pos);
84
  }
 
3
  import { G } from './globals.js';
4
  import { getTerrainHeight, getNearbyTrees } from './world.js';
5
 
6
+ // Working vectors
7
  const FWD = new THREE.Vector3();
8
  const RIGHT = new THREE.Vector3();
9
  const NEXT = new THREE.Vector3();
10
  const PREV = new THREE.Vector3();
11
 
12
+ // Helpers implementing Quake/Source-like movement in XZ plane
13
+ function applyFriction(vel, friction, stopSpeed, dt) {
14
+ const vx = vel.x, vz = vel.z;
15
+ const speed = Math.hypot(vx, vz);
16
+ if (speed <= 0.0001) return;
17
+ const control = Math.max(speed, stopSpeed);
18
+ const drop = control * friction * dt;
19
+ const newSpeed = Math.max(0, speed - drop);
20
+ if (newSpeed !== speed) {
21
+ const k = newSpeed / speed;
22
+ vel.x *= k;
23
+ vel.z *= k;
24
+ }
25
+ }
26
+
27
+ function accelerate(vel, wishDir, wishSpeed, accel, dt) {
28
+ const current = vel.x * wishDir.x + vel.z * wishDir.z;
29
+ let add = wishSpeed - current;
30
+ if (add <= 0) return;
31
+ const push = Math.min(accel * wishSpeed * dt, add);
32
+ vel.x += wishDir.x * push;
33
+ vel.z += wishDir.z * push;
34
+ }
35
+
36
+ function airAccelerate(vel, wishDir, wishSpeedCap, airAccel, dt) {
37
+ const current = vel.x * wishDir.x + vel.z * wishDir.z;
38
+ const wishSpeed = Math.min(wishSpeedCap, Math.hypot(wishDir.x, wishDir.z) > 0 ? wishSpeedCap : 0);
39
+ let add = wishSpeed - current;
40
+ if (add <= 0) return;
41
+ const push = Math.min(airAccel * wishSpeed * dt, add);
42
+ vel.x += wishDir.x * push;
43
+ vel.z += wishDir.z * push;
44
+ }
45
+
46
  export function updatePlayer(delta) {
47
  if (!G.player.alive) return;
48
 
49
+ const P = G.player;
50
+ const M = CFG.player.move;
51
 
52
+ // Forward/right in the horizontal plane
53
  G.camera.getWorldDirection(FWD);
54
+ FWD.y = 0; FWD.normalize();
55
+ RIGHT.crossVectors(FWD, G.camera.up).normalize();
56
 
57
+ // Build wish direction from inputs
58
+ let wishX = 0, wishZ = 0;
59
+ if (G.input.w) { wishX += FWD.x; wishZ += FWD.z; }
60
+ if (G.input.s) { wishX -= FWD.x; wishZ -= FWD.z; }
61
+ if (G.input.d) { wishX += RIGHT.x; wishZ += RIGHT.z; }
62
+ if (G.input.a) { wishX -= RIGHT.x; wishZ -= RIGHT.z; }
63
+ const wishLen = Math.hypot(wishX, wishZ);
64
+ if (wishLen > 0.0001) { wishX /= wishLen; wishZ /= wishLen; }
65
 
66
+ // Desired speeds
67
+ let baseSpeed = P.speed * (G.input.sprint ? CFG.player.sprintMult : 1);
68
+ const crouchMult = (CFG.player.crouchMult || 1);
69
+ // If not sliding, crouch reduces speed
70
+ if (G.input.crouch && !P.sliding) baseSpeed *= crouchMult;
71
 
72
+ // Timers: jump buffer and coyote time
73
+ // Buffer jump on key press (edge), not hold
74
+ if (G.input.jump && !P.jumpHeld) {
75
+ P.jumpBuffer = M.jumpBuffer;
76
+ } else {
77
+ P.jumpBuffer = Math.max(0, P.jumpBuffer - delta);
78
  }
79
+ P.jumpHeld = !!G.input.jump;
80
 
81
+ if (P.grounded) P.coyoteTimer = M.coyoteTime; else P.coyoteTimer = Math.max(0, P.coyoteTimer - delta);
82
+ P.wallContactTimer = Math.max(0, P.wallContactTimer - delta);
83
 
84
+ // Sliding enter/exit
85
+ const horizSpeed = Math.hypot(P.vel.x, P.vel.z);
86
+ if (P.grounded && G.input.crouch && (horizSpeed >= M.slideMinSpeed)) {
87
+ P.sliding = true;
88
+ } else if (!G.input.crouch || !P.grounded) {
89
+ P.sliding = false;
90
+ }
91
+
92
+ // Jump handling (ground, coyote, or wall bounce)
93
+ let skippedFriction = false;
94
+ if (P.jumpBuffer > 0) {
95
+ if (P.coyoteTimer > 0) {
96
+ // Ground/coyote jump
97
+ P.yVel = M.jumpSpeed;
98
+ // Slide-jump: preserve momentum and add small boost
99
+ if (P.sliding) {
100
+ const sp = Math.hypot(P.vel.x, P.vel.z);
101
+ if (sp > 0.0001) {
102
+ const nx = P.vel.x / sp, nz = P.vel.z / sp;
103
+ P.vel.x += nx * M.slideJumpBoost;
104
+ P.vel.z += nz * M.slideJumpBoost;
105
+ }
106
+ skippedFriction = true;
107
+ }
108
+ P.grounded = false;
109
+ P.jumpBuffer = 0; // consume
110
+ P.coyoteTimer = 0;
111
+ } else if (!P.grounded && P.wallContactTimer > 0) {
112
+ // Wall bounce: reflect into-wall component and add outward pop
113
+ const n = P.lastWallNormal;
114
+ const dot = P.vel.x * n.x + P.vel.z * n.z;
115
+ // Remove into-wall component
116
+ P.vel.x -= n.x * dot;
117
+ P.vel.z -= n.z * dot;
118
+ // Add outward impulse
119
+ P.vel.x += n.x * M.wallBounceImpulse;
120
+ P.vel.z += n.z * M.wallBounceImpulse;
121
+ // Give a small jump
122
+ P.yVel = M.jumpSpeed;
123
+ P.jumpBuffer = 0;
124
+ P.wallContactTimer = 0;
125
+ }
126
+ }
127
+
128
+ // State-based friction and acceleration
129
+ if (P.grounded) {
130
+ // Friction (skip on slide-jump frame)
131
+ if (!skippedFriction) {
132
+ const fric = P.sliding ? M.slideFriction : M.friction;
133
+ applyFriction(P.vel, fric, M.stopSpeed, delta);
134
+ }
135
+ // Accelerate toward wishdir
136
+ accelerate(P.vel, { x: wishX, z: wishZ }, baseSpeed, P.sliding ? M.slideAccel : M.groundAccel, delta);
137
+ } else {
138
+ // Air movement
139
+ airAccelerate(P.vel, { x: wishX, z: wishZ }, M.airSpeedCap, M.airAccel, delta);
140
+ }
141
+
142
+ // Gravity (vertical only)
143
+ P.yVel -= M.gravity * delta;
144
+
145
+ // Integrate position
146
+ NEXT.copy(P.pos);
147
+ NEXT.x += P.vel.x * delta;
148
+ NEXT.z += P.vel.z * delta;
149
+ NEXT.y += P.yVel * delta;
150
+
151
+ // Collide with tree trunks (cylinders in XZ), push out
152
  const nearTrees = getNearbyTrees(NEXT.x, NEXT.z, 3.5);
153
  for (let i = 0; i < nearTrees.length; i++) {
154
  const tree = nearTrees[i];
155
  const dx = NEXT.x - tree.x;
156
  const dz = NEXT.z - tree.z;
157
+ const dist = Math.hypot(dx, dz);
158
+ const minDist = P.radius + tree.radius;
159
  if (dist < minDist && dist > 0) {
160
+ const nx = dx / dist;
161
+ const nz = dz / dist;
162
+ const push = (minDist - dist);
163
+ NEXT.x += nx * push;
164
+ NEXT.z += nz * push;
165
+ // Record wall contact for potential wall-bounce when airborne
166
+ if (!P.grounded) {
167
+ P.lastWallNormal.set(nx, 0, nz);
168
+ P.wallContactTimer = Math.max(P.wallContactTimer, CFG.player.move.wallBounceWindow);
169
+ }
170
  }
171
  }
172
 
173
+ // Bounds clamp
174
+ const halfSize = CFG.forestSize / 2 - P.radius;
175
  NEXT.x = Math.max(-halfSize, Math.min(halfSize, NEXT.x));
176
  NEXT.z = Math.max(-halfSize, Math.min(halfSize, NEXT.z));
177
 
178
+ // Ground resolve against terrain (using eye height)
 
 
 
 
179
  const eye = G.input.crouch ? (CFG.player.crouchEyeHeight || 1.8) : (CFG.player.eyeHeight || 1.8);
180
  const groundEye = getTerrainHeight(NEXT.x, NEXT.z) + eye;
181
  if (NEXT.y <= groundEye) {
182
  NEXT.y = groundEye;
183
+ P.yVel = 0;
184
+ P.grounded = true;
185
  } else {
186
+ P.grounded = false;
187
  }
188
 
189
+ // Commit position and keep camera in sync
190
+ PREV.copy(P.pos);
191
+ P.pos.copy(NEXT);
192
+ G.camera.position.copy(P.pos);
 
 
 
 
 
193
  }