Spaces:
Running
Running
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- src/config.js +22 -1
- src/events.js +12 -6
- src/hud.js +1 -1
- src/main.js +15 -1
- 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'
|
53 |
-
//
|
54 |
-
G.
|
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>
|
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 |
-
|
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 |
-
|
16 |
-
|
17 |
|
|
|
18 |
G.camera.getWorldDirection(FWD);
|
19 |
-
FWD.y = 0;
|
20 |
-
FWD.normalize();
|
21 |
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
|
|
28 |
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
|
|
34 |
}
|
|
|
35 |
|
36 |
-
|
37 |
-
|
38 |
|
39 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
46 |
-
const minDist =
|
47 |
if (dist < minDist && dist > 0) {
|
48 |
-
const
|
49 |
-
const
|
50 |
-
|
51 |
-
NEXT.
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
}
|
53 |
}
|
54 |
|
55 |
-
// Bounds
|
56 |
-
const halfSize = CFG.forestSize / 2 -
|
57 |
NEXT.x = Math.max(-halfSize, Math.min(halfSize, NEXT.x));
|
58 |
NEXT.z = Math.max(-halfSize, Math.min(halfSize, NEXT.z));
|
59 |
|
60 |
-
//
|
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 |
-
|
70 |
-
|
71 |
} else {
|
72 |
-
|
73 |
}
|
74 |
|
75 |
-
//
|
76 |
-
PREV.copy(
|
77 |
-
|
78 |
-
|
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 |
}
|