Skip to content

Commit 3ac9633

Browse files
fix: gameplay polish — movement, feel, and atmosphere
- NPC distance-based paths, sane step size, walk speeds 1–1.5 u/s - Slow traffic with off-screen respawn; birds/butterflies orbit player gently - Slower player walk, working head bob, look smoothing, tree collision - Night-linked fog, fill lights, lamps/windows, fireflies; shadow follows player - Cinematic camera sync, desktop high quality, FXAA-only antialiasing Co-authored-by: Shuvo Anirban Roy <anirbanroy691@gmail.com>
1 parent a9ed03e commit 3ac9633

15 files changed

Lines changed: 516 additions & 409 deletions

docs/FEATURES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
Default build: `FEATURE_WEAPON = false`, `FEATURE_MINIGAMES = true` in `config.js`.
44

5+
## Movement tuning (units per second)
6+
7+
| Entity | Speed |
8+
|--------|-------|
9+
| Player | ~1.8 |
10+
| NPCs | 1.0–1.5 |
11+
| Traffic | ~1.2–2.2 |
12+
| Dog | ~2.0 |
13+
14+
NPCs use distance-based pathing (no segment sprinting). Traffic respawns off-screen, not beside the player.
15+
516
## Controls
617

718
| Key | Action |

js/cinematic.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import * as THREE from 'three';
1414
import { PLAYER_HEIGHT } from './config.js';
15+
import { syncPlayerLookFromCamera } from './controls.js';
1516

1617
let isPlaying = false;
1718
let cinematicTime = 0;
@@ -90,6 +91,7 @@ export function updateCinematic(delta, camera) {
9091
// Check completion
9192
if (t >= 1) {
9293
isPlaying = false;
94+
syncPlayerLookFromCamera(camera);
9395
const overlay = document.getElementById('cinematic-overlay');
9496
if (overlay) {
9597
overlay.style.opacity = '0';
@@ -137,9 +139,10 @@ function smoothstep(t) {
137139
/**
138140
* Skip the cinematic intro.
139141
*/
140-
export function skipCinematic() {
142+
export function skipCinematic(camera) {
141143
if (!isPlaying) return;
142144
cinematicTime = CINEMATIC_DURATION;
145+
if (camera) syncPlayerLookFromCamera(camera);
143146
}
144147

145148
export function isCinematicPlaying() { return isPlaying; }

js/city.js

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import {
2626

2727
// ── Shared materials (created once, reused everywhere) ───────
2828
const buildingMats = BUILDING_COLORS.map(c => new THREE.MeshLambertMaterial({ color: c }));
29-
const windowLitMat = new THREE.MeshBasicMaterial({ color: 0xFFE082 });
30-
const windowDimMat = new THREE.MeshBasicMaterial({ color: 0xFFF8E1 });
29+
const windowLitMat = new THREE.MeshBasicMaterial({ color: 0xFFE082, transparent: true, opacity: 0.5 });
30+
const windowDimMat = new THREE.MeshBasicMaterial({ color: 0xFFF8E1, transparent: true, opacity: 0.2 });
3131
const sidewalkMat = new THREE.MeshLambertMaterial({ color: 0xC8C0B0 });
3232
const stoneMat = new THREE.MeshLambertMaterial({ color: 0xBDBDBD });
3333
const waterMat = new THREE.MeshLambertMaterial({ color: 0x4FC3F7, transparent: true, opacity: 0.7 });
@@ -498,9 +498,10 @@ function createLampPost(scene, x, z, hasLight) {
498498

499499
// Only a few lamps get actual PointLights (performance)
500500
if (hasLight) {
501-
const light = new THREE.PointLight(0xFFE082, 0.4, 15);
501+
const light = new THREE.PointLight(0xFFE082, 0.25, 15);
502502
light.position.set(x + 1.1, 4.5, z);
503503
scene.add(light);
504+
lampPointLights.push(light);
504505
}
505506
}
506507

@@ -523,3 +524,56 @@ export function isInsideBuilding(x, z, padding = 1) {
523524
}
524525
return false;
525526
}
527+
528+
/**
529+
* @param {number} x
530+
* @param {number} z
531+
* @param {number} [padding=0]
532+
*/
533+
export function isBlockedByTree(x, z, padding = 0) {
534+
for (let i = 0; i < treeData.length; i++) {
535+
const t = treeData[i];
536+
const r = (t.large ? 1.1 : 0.75) + padding;
537+
const dx = x - t.x;
538+
const dz = z - t.z;
539+
if (dx * dx + dz * dz < r * r) return true;
540+
}
541+
return false;
542+
}
543+
544+
/** @type {THREE.PointLight[]} */
545+
const lampPointLights = [];
546+
547+
/**
548+
* Adjust street and window lighting for time of day.
549+
* @param {number} nightAmount 0..1
550+
*/
551+
export function updateCityLighting(nightAmount) {
552+
const warm = 0xFFE082;
553+
const cool = 0xFFE8CC;
554+
const n = Math.max(0, Math.min(1, nightAmount));
555+
const t = Math.max(0, (n - 0.2) / 0.8);
556+
557+
lampGlowMat.color.setHex(lerpHex(cool, warm, t));
558+
windowLitMat.color.setHex(lerpHex(0xFFF3D0, 0xFFE082, t));
559+
windowLitMat.opacity = 0.35 + t * 0.55;
560+
windowDimMat.opacity = 0.15 + t * 0.1;
561+
562+
const lightIntensity = 0.08 + t * 0.45;
563+
for (const light of lampPointLights) {
564+
light.intensity = lightIntensity;
565+
}
566+
}
567+
568+
function lerpHex(a, b, t) {
569+
const ar = (a >> 16) & 255;
570+
const ag = (a >> 8) & 255;
571+
const ab = a & 255;
572+
const br = (b >> 16) & 255;
573+
const bg = (b >> 8) & 255;
574+
const bb = b & 255;
575+
const r = Math.round(ar + (br - ar) * t);
576+
const g = Math.round(ag + (bg - ag) * t);
577+
const bl = Math.round(ab + (bb - ab) * t);
578+
return (r << 16) | (g << 8) | bl;
579+
}

js/config.js

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,27 @@ export const TRAFFIC_CULL_DISTANCE = 90;
2424
export const WILDLIFE_CULL_DISTANCE = 70;
2525

2626
// ── Player ───────────────────────────────────────────────────
27-
export const WALK_SPEED = 0.04;
27+
export const WALK_SPEED = 0.03;
2828
export const LOOK_SPEED = 0.002;
2929
export const TOUCH_LOOK_SPEED = 0.005;
3030
export const PLAYER_HEIGHT = 3.5;
31-
export const COLLISION_PADDING = 1.5;
31+
export const COLLISION_PADDING = 0.9;
3232
export const CITY_BOUND_MARGIN = 2;
33-
export const HEAD_BOB_SPEED = 0.005;
34-
export const HEAD_BOB_AMOUNT = 0.04;
33+
export const HEAD_BOB_SPEED = 8;
34+
export const HEAD_BOB_AMOUNT = 0.05;
3535
export const HEAD_BOB_THRESHOLD = 0.1;
3636

37-
// ── NPCs ─────────────────────────────────────────────────────
37+
// ── NPCs (speeds in world units per second) ──────────────────
3838
export const NPC_COUNT = 25;
39-
export const NPC_MIN_SPEED = 0.015;
40-
export const NPC_SPEED_RANGE = 0.015;
39+
export const NPC_WALK_SPEED_MIN = 1.0;
40+
export const NPC_WALK_SPEED_MAX = 1.5;
41+
/** @deprecated use NPC_WALK_SPEED_MIN/MAX */
42+
export const NPC_MIN_SPEED = NPC_WALK_SPEED_MIN;
43+
export const NPC_SPEED_RANGE = NPC_WALK_SPEED_MAX - NPC_WALK_SPEED_MIN;
4144
export const NPC_PATH_MIN_POINTS = 4;
4245
export const NPC_PATH_EXTRA_POINTS = 6;
43-
export const NPC_PATH_STEP = 40;
46+
export const NPC_PATH_STEP = 10;
47+
export const NPC_MIN_SEGMENT_LEN = 2;
4448
export const NPC_BOB_SPEED = 8;
4549
export const NPC_BOB_AMOUNT = 0.08;
4650
export const NPC_CULL_DISTANCE = 80; // only update NPCs within this radius

js/controls.js

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
HEAD_BOB_SPEED, HEAD_BOB_AMOUNT, HEAD_BOB_THRESHOLD,
1717
CONTROLS_HINT_FADE_DELAY
1818
} from './config.js';
19-
import { isInsideBuilding } from './city.js';
19+
import { isInsideBuilding, isBlockedByTree } from './city.js';
2020
import { canPlayerMove } from './player-input.js';
2121

2222
/** Player state */
@@ -39,8 +39,12 @@ let lookTouchId = null;
3939
let lookLastX = 0;
4040
let lookLastY = 0;
4141

42-
/** Accumulated time for head bob (uses clock, not Date.now()) */
43-
let bobTime = 0;
42+
/** Head bob phase (radians) */
43+
let bobPhase = 0;
44+
45+
/** Smoothed look (optional lag) */
46+
let viewYaw = 0;
47+
let viewPitch = 0;
4448

4549
/** Controls hint visibility */
4650
let controlsHintVisible = true;
@@ -68,8 +72,9 @@ export function setupControls(renderer, camera) {
6872
rendererRef = renderer;
6973
cameraRef = camera;
7074

71-
// Set rotation order once (not every frame)
7275
camera.rotation.order = 'YXZ';
76+
viewYaw = player.yaw;
77+
viewPitch = player.pitch;
7378

7479
// Keyboard — support both e.code (positional) and e.key (layout-aware)
7580
document.addEventListener('keydown', (e) => {
@@ -245,13 +250,15 @@ export function updatePlayer(delta, camera) {
245250
const newX = player.x + (moveX * cosYaw + moveZ * sinYaw) * speed;
246251
const newZ = player.z + (-moveX * sinYaw + moveZ * cosYaw) * speed;
247252

248-
// Sliding collision detection
249-
if (!isInsideBuilding(newX, newZ, COLLISION_PADDING)) {
253+
const blocked = (x, z) =>
254+
isInsideBuilding(x, z, COLLISION_PADDING) || isBlockedByTree(x, z);
255+
256+
if (!blocked(newX, newZ)) {
250257
player.x = newX;
251258
player.z = newZ;
252-
} else if (!isInsideBuilding(newX, player.z, COLLISION_PADDING)) {
259+
} else if (!blocked(newX, player.z)) {
253260
player.x = newX;
254-
} else if (!isInsideBuilding(player.x, newZ, COLLISION_PADDING)) {
261+
} else if (!blocked(player.x, newZ)) {
255262
player.z = newZ;
256263
}
257264

@@ -263,16 +270,29 @@ export function updatePlayer(delta, camera) {
263270
// Update camera position
264271
camera.position.set(player.x, PLAYER_HEIGHT, player.z);
265272

266-
// Gentle head bob when moving (uses accumulated clock time, not Date.now())
267273
if (len > HEAD_BOB_THRESHOLD) {
268-
bobTime += delta;
269-
const bobAmount = Math.sin(bobTime / HEAD_BOB_SPEED * 0.001) * HEAD_BOB_AMOUNT;
270-
camera.position.y += bobAmount;
274+
bobPhase += delta * HEAD_BOB_SPEED;
275+
camera.position.y += Math.abs(Math.sin(bobPhase)) * HEAD_BOB_AMOUNT;
271276
}
272277

273-
// Camera rotation (order already set in setupControls)
274-
camera.rotation.y = player.yaw;
275-
camera.rotation.x = player.pitch;
278+
const lookLerp = 1 - Math.exp(-14 * delta);
279+
viewYaw += (player.yaw - viewYaw) * lookLerp;
280+
viewPitch += (player.pitch - viewPitch) * lookLerp;
281+
camera.rotation.y = viewYaw;
282+
camera.rotation.x = viewPitch;
283+
}
284+
285+
/**
286+
* Align player look with camera (after cinematic).
287+
* @param {THREE.PerspectiveCamera} camera
288+
*/
289+
export function syncPlayerLookFromCamera(camera) {
290+
const euler = new THREE.Euler(0, 0, 0, 'YXZ');
291+
euler.setFromQuaternion(camera.quaternion, 'YXZ');
292+
player.yaw = euler.y;
293+
player.pitch = euler.x;
294+
viewYaw = player.yaw;
295+
viewPitch = player.pitch;
276296
}
277297

278298
/**

js/dog.js

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,27 +139,25 @@ export function updateDog(delta, elapsed, playerPos) {
139139
const dz = playerPos.z - dogPos.z;
140140
const dist = Math.sqrt(dx * dx + dz * dz);
141141

142-
// Follow player if too far away (> 4 units)
143-
if (dist > 4) {
144-
// Move toward player but stop at ~2.5 units
145-
const targetDist = 2.5;
142+
if (dist > 5) {
143+
const targetDist = 3;
146144
const angle = Math.atan2(dz, dx);
147145
dogTargetPos.x = playerPos.x - Math.cos(angle) * targetDist;
148146
dogTargetPos.z = playerPos.z - Math.sin(angle) * targetDist;
149147
dogIsMoving = true;
150148
} else if (dist < 2) {
151-
// Too close, back off slightly
152-
const targetDist = 2.5;
149+
const targetDist = 3;
153150
const angle = Math.atan2(dz, dx);
154151
dogTargetPos.x = playerPos.x - Math.cos(angle) * targetDist;
155152
dogTargetPos.z = playerPos.z - Math.sin(angle) * targetDist;
156153
dogIsMoving = true;
157154
} else {
158155
dogIsMoving = false;
156+
dogTargetPos.x = dogPos.x;
157+
dogTargetPos.z = dogPos.z;
159158
}
160159

161-
// Smooth movement toward target
162-
const moveSpeed = 3.0 * delta;
160+
const moveSpeed = 2.0 * delta;
163161
const tdx = dogTargetPos.x - dogPos.x;
164162
const tdz = dogTargetPos.z - dogPos.z;
165163
const tDist = Math.sqrt(tdx * tdx + tdz * tdz);

js/game-loop.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { updatePlayer, player } from './controls.js';
88
import { updateNPCs } from './npcs.js';
99
import { updateDayNight, getCycleTime, getNightAmount } from './lighting.js';
10+
import { updateCityLighting } from './city.js';
1011
import { updateParticles } from './particles.js';
1112
import { updateDog } from './dog.js';
1213
import { updateTraffic } from './traffic.js';
@@ -45,7 +46,7 @@ export function tick(ctx) {
4546

4647
if (isCinematicPlaying()) {
4748
updateCinematic(delta, camera);
48-
updateDayNight(delta, scene);
49+
updateDayNight(delta, scene, player);
4950
composer.render();
5051
return { lastPlayerPos };
5152
}
@@ -57,15 +58,15 @@ export function tick(ctx) {
5758

5859
if (isPhotoModeActive()) {
5960
updatePhotoMode(camera);
60-
updateDayNight(delta, scene);
61+
updateDayNight(delta, scene, player);
6162
updateParticles(delta, elapsed, player);
6263
composer.render();
6364
return { lastPlayerPos };
6465
}
6566

6667
if (isMeditationActive()) {
6768
updateMeditation(delta, camera);
68-
updateDayNight(delta, scene);
69+
updateDayNight(delta, scene, player);
6970
updateParticles(delta, elapsed, player);
7071
updateWildlife(delta, elapsed, player);
7172
composer.render();
@@ -80,7 +81,9 @@ export function tick(ctx) {
8081
lastPlayerPos = { x: player.x, z: player.z };
8182

8283
updateNPCs(delta, player);
83-
updateDayNight(delta, scene);
84+
updateDayNight(delta, scene, player);
85+
const nightAmount = getNightAmount();
86+
updateCityLighting(nightAmount);
8487
updateParticles(delta, elapsed, player);
8588
updateDog(delta, elapsed, player);
8689
updateTraffic(delta, player);
@@ -90,9 +93,11 @@ export function tick(ctx) {
9093
updateMiniGame(delta, elapsed, player, scene);
9194
}
9295

93-
const nightAmount = getNightAmount();
9496
const cycleTime = getCycleTime();
9597
if (nightAmount > 0.7) session.nightSeen = true;
98+
99+
const targetExposure = 0.88 - nightAmount * 0.22;
100+
renderer.toneMappingExposure += (targetExposure - renderer.toneMappingExposure) * Math.min(1, delta * 0.5);
96101
updateAudioTimeOfDay(nightAmount);
97102

98103
const collectResult = updateCollectibles(delta, elapsed, player, scene);

js/input.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function setupGameInput(ctx) {
3131

3232
document.addEventListener('keydown', (e) => {
3333
if (isCinematicPlaying()) {
34-
skipCinematic();
34+
skipCinematic(camera);
3535
return;
3636
}
3737

0 commit comments

Comments
 (0)