Category: Parkour
-
climb – loop
-
Building a Visual AttackβNPC Pairing Editor in Three.js
A browser-based tool for choreographing combat animations without recompiling your game.
What This Tool Does
You have a set of player attack animations and a set of NPC reaction animations. You need to define, for each attack:
- Which NPC reaction(s) fireΒ β one or more, chosen randomly
- Where the NPC standsΒ β per-attack distance in meters
- When the reaction firesΒ β as a fraction of the attack clip (e.g. 60%)
- How fast the reaction playsΒ β a speed multiplier
Doing this by editing JSON and refreshing the game is agonizing. This tool lets you do it visually, preview it live in 3D, and export the config as a single JSON file your game reads at runtime.
Feature List
Feature What it does Auto-discovery + fallback Loads GLBs from folder listing or a hardcoded list Auto-classification Files starting with NPC_are NPC assets; everything else is a player attackBlender-style editor Bottom split-pane that keeps the 3D view visible Live previews Click βΆ on any row to play that clip on the 3D scene Per-attack sliders Range, hit timing, and NPC speed β all saved per attack Auto-preview on release Releasing a slider plays the result instantly localStorage persistence Pairings save automatically, survive refreshes Schema migration Old saved data auto-upgrades to newer formats JSON export / import The config contract between tool and game Static minimum range Slider can’t go below a value that would clip the models
Architecture
text
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β PAIRING EDITOR (browser tool) β β β β β’ Loads GLBs, classifies player vs NPC β β β’ Bottom pane: attacks Γ NPCs β β β’ βΆ preview per row β β β’ + / Γ to link / unlink β β β’ 3 sliders per attack: range, hitAt, npcSpeed β β β’ Auto-saves to localStorage β β β’ β Import / β Export as JSON β ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ β βΌ fight_pairings.json βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β GAME RUNTIME β β β β β’ Loads GLBs, reads pairings.json once β β β’ On attack: look up entry, position NPC, schedule β β reaction, play at tuned speed β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββThe tool’s only job is to produce the JSON. Everything else β the UI, the storage, the previews β exists to make that JSON cheap to produce and easy to iterate.
Feature 1 β File Loading That Never Fails
Browsers can’t enumerate a folder. But a directory listing (like Python’s
http.servergenerates) exposes<a href="file.glb">links we can scrape. The problem: as soon as you add anindex.html, the listing disappears.Solution: hybrid loading. Try to scrape; merge the result with a hardcoded fallback list. The tool always has files to work with.
js
const FALLBACK_FILES = [ 'Boxing_clean.glb', 'flying_kick_clean.glb', 'Headbutt_clean.glb', 'Kicking_clean.glb', 'leg_sweep_clean.glb', 'martelo_2_clean.glb', 'quad_punch_clean.glb', 'roundhouse_kick_clean.glb', 'surprise_uppercut_clean.glb', 'NPC_receive_punch_to_the_face_clean.glb', 'NPC_receive_stomach_uppercut_clean.glb', 'NPC_surprise_uppercut_clean.glb', 'NPC_taking_punch_clean.glb', 'NPC_knocked_out_clean.glb', 'NPC_standing_death_backward_01_clean.glb', 'NPC_standing_death_forward_01_clean.glb' ]; async function discoverFiles(){ try { const res = await fetch(location.href, { cache: 'no-store' }); if (!res.ok) return []; const text = await res.text(); const re = /href\s*=\s*["']([^"']+\.glb)["']/gi; const found = new Set(); let m; while ((m = re.exec(text)) !== null){ const name = decodeURIComponent(m[1]).split('/').pop(); if (name) found.add(name); } return [...found]; } catch { return []; } } /* In boot(): */ const discovered = await discoverFiles(); const merged = Array.from(new Set([...FALLBACK_FILES, ...discovered])) .filter(n => /\.glb$/i.test(n));Also dedupes
foo.glbvsfoo_clean.glbβ keeping only the_cleanone:js
function dedupeClean(files){ const set = new Set(files); return files.filter(f => { const plain = f.replace(/_clean\.glb$/i, '.glb'); if (plain === f) return true; return !set.has(plain); }); }
Feature 2 β Player vs NPC Classification
Naming convention does all the work:
js
const idleFile = allFiles.find(f => /NPC_bouncing_fight_idle/i.test(f)) || null; const reactionFiles = allFiles.filter(f => /^NPC_/i.test(f) && f !== idleFile); const attackFiles = allFiles.filter(f => !/^NPC_/i.test(f));
NPC_*.glbΒ β NPC reaction poolNPC_bouncing_fight_idle_clean.glbΒ β special: NPC base mesh + idle loop- Everything else β player attack
Both loaders re-center their mesh on origin and reset the feet to Y=0:
js
function prepareCharacter(root){ root.updateMatrixWorld(true); const box = new THREE.Box3().setFromObject(root); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); root.position.x -= center.x; root.position.z -= center.z; root.position.y -= box.min.y; root.updateMatrixWorld(true); return size; }
Feature 3 β The Blender-Inspired Pairing Editor
The editor opens from the bottom of the viewport. The 3D scene stays visible above it. This is the single most important UX decision β every tuning action has immediate visual feedback.
text
ββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ β SIDEBAR β 3D VIEWPORT β ββββββββββββ΄βββββββββββββββββββββββββββββββββββββββ€ β PAIRING EDITOR [Γ close] β β βββββββββββββββββββββββββββββββββββββββββββββ β β ATTACKS β NPC POOL β β βΆ Boxing [2] β βΆ Block + β β βΆ Kicking [1] β βΆ Face Hit + β βββββββββββββββββββββββββββββββββββββββββββββββββββ
The panel is one
<section>in the layout, toggled by a class:js
function openPairing(){ pairingEl.classList.remove('hidden'); pairSelectedIndex = currentAttackIndex; renderPairingAttacks(); renderPairingNpcs(); renderPairingFoot(); }Each attack track shows:
- βΆΒ β preview button
- NameΒ β click to select this attack
- BadgeΒ β how many NPC reactions are assigned
- Timeline barΒ β clip duration with a pink marker at the hit frame
- ChipsΒ β one per assigned NPC, with anΒ ΓΒ to remove
- SlidersΒ β only shown when this attack is selected
Feature 4 β Live Previews
Any row can be previewed by clicking βΆ. This plays the clip on the 3D scene without committing anything.
js
function previewAttack(index){ const c = playerClips[index]; previewing = true; // suppress auto-reactions attacking = false; /* cancel any in-flight attack state */ clearTimeout(attackEndTimer); clearTimeout(hitTimer); /* move NPC to this attack's tuned range */ moveNpcTo(c.file); playerMixer.stopAllAction(); playerAction = playerMixer.clipAction(c.clip); playerAction.reset(); playerAction.setEffectiveTimeScale(speed); playerAction.setLoop(THREE.LoopOnce, 1); playerAction.clampWhenFinished = true; playerAction.play(); setPlayingIndicator(-1, index); previewEndTimer = setTimeout(() => { previewing = false; setPlayingIndicator(-1); }, (c.clip.duration || 1) * 1000 / speed + 200); } function previewNpc(npcFile, overrideSpeed = null){ const r = npcReactionClips.find(x => x.file === npcFile); if (!r || !npcMixer) return; const chosenAttackFile = playerClips[pairSelectedIndex]?.file || playerClips[currentAttackIndex]?.file; const spdMul = overrideSpeed ?? npcSpeedFor(chosenAttackFile); if (npcIdleAction) npcIdleAction.fadeOut(0.12); if (npcReactionAction){ npcReactionAction.stop(); npcReactionAction = null; } npcReactionAction = npcMixer.clipAction(r.clip); npcReactionAction.reset(); npcReactionAction.setEffectiveTimeScale(speed * spdMul); npcReactionAction.setLoop(THREE.LoopOnce, 1); npcReactionAction.clampWhenFinished = true; npcReactionAction.fadeIn(0.1).play(); const dur = (r.clip.duration || 1) * 1000 / (speed * spdMul); reactionEndTimer = setTimeout(() => { if (!npcReactionAction) return; npcReactionAction.fadeOut(0.2); if (npcIdleAction){ npcIdleAction.reset(); npcIdleAction.setEffectiveTimeScale(speed); npcIdleAction.setLoop(THREE.LoopRepeat, Infinity); npcIdleAction.fadeIn(0.2).play(); } npcReactionAction = null; setPlayingIndicator(-1); }, dur); }The currently-playing row highlights red via
setPlayingIndicator:js
function setPlayingIndicator(npcFile, attackIndex){ document.querySelectorAll('.atk-track').forEach((el, i) => { el.classList.toggle('playing', i === attackIndex); }); document.querySelectorAll('.npc-pool-row').forEach(el => { el.classList.toggle('playing', el.dataset.file === npcFile); }); }
Feature 5 β Per-Attack Metadata: Three Sliders
Each attack stores three tunables:
Slider Range Meaning β Range 0.35 β 4.0 m Where the NPC stands π― Hit At 5% β 95% When the reaction fires (fraction of clip) β± Speed 0.5Γ β 2.5Γ Reaction playback speed Defaults live at the top of the script:
js
const DEFAULT_RANGE = 1.6; const MIN_RANGE = 0.5; const MAX_RANGE = 4.0; const DEFAULT_NPC_SPEED = 1.0; const MIN_NPC_SPEED = 0.5; const MAX_NPC_SPEED = 2.5; const DEFAULT_HIT_AT = 0.60; const MIN_HIT_AT = 0.05; const MAX_HIT_AT = 0.95;
Only the selected attack renders its sliders, keeping the panel clean:
js
if (isSel){ /* Range slider */ const rangeWrap = document.createElement('div'); rangeWrap.className = 'atk-range first'; const rInput = document.createElement('input'); rInput.type = 'range'; rInput.min = Math.max(MIN_RANGE, minSeparation); // β collision floor rInput.max = MAX_RANGE; rInput.step = 0.05; rInput.value = range; rInput.addEventListener('input', () => { const v = parseFloat(rInput.value); setRangeFor(c.file, v); if (playerClips[currentAttackIndex]?.file === c.file){ npcBaseTargetZ = v; npcTargetZ = v; updateDistanceGuide(); } updateStatusForAttack(c); }); /* ...append to track... */ /* Hit timing slider */ const tInput = document.createElement('input'); tInput.type = 'range'; tInput.min = MIN_HIT_AT; tInput.max = MAX_HIT_AT; tInput.step = 0.01; tInput.value = hitAt; tInput.addEventListener('input', () => { const v = parseFloat(tInput.value); setHitAtFor(c.file, v); marker.style.left = (v * 100) + '%'; // live timeline marker }); /* auto-play on release so the user *sees* the new timing */ tInput.addEventListener('pointerup', () => previewAttack(i)); /* Speed slider */ const sInput = document.createElement('input'); sInput.type = 'range'; sInput.min = MIN_NPC_SPEED; sInput.max = MAX_NPC_SPEED; sInput.step = 0.05; sInput.value = npcSpd; sInput.addEventListener('input', () => { const v = parseFloat(sInput.value); setNpcSpeedFor(c.file, v); }); sInput.addEventListener('pointerup', () => { const r = reactionsFor(c.file); if (!r.length) return; const pick = r[Math.floor(Math.random() * r.length)]; previewNpc(pick, npcSpeedFor(c.file)); }); }The auto-preview on release is the single biggest usability win. No “apply” button, no separate “test” button β you drag, let go, and see the result.
Feature 6 β Hit Timing in the Attack Loop
When the player actually attacks (not a preview), the hit timer uses the attack’s
hitAt:js
function triggerAttack(){ if (!playerClips.length || attacking) return; attacking = true; previewing = false; btnFight.disabled = true; const entry = playerClips[currentAttackIndex]; const clip = entry.clip; const duration = clip.duration || 1; const hitAt = hitAtFor(entry.file); // β per-attack if (playerRoot) playerRoot.position.x = playerRoot.position.z = 0; playerMixer.stopAllAction(); playerAction = playerMixer.clipAction(clip); playerAction.reset(); playerAction.setEffectiveTimeScale(speed); playerAction.setLoop(THREE.LoopOnce, 1); playerAction.clampWhenFinished = true; playerAction.play(); clearTimeout(hitTimer); hitTimer = setTimeout( () => fireNpcReaction(entry.file), duration * hitAt * 1000 / speed ); clearTimeout(attackEndTimer); attackEndTimer = setTimeout(() => { attacking = false; btnFight.disabled = false; if (playerRoot) playerRoot.position.x = playerRoot.position.z = 0; setPlayingIndicator(-1); }, duration * 1000 / speed + 200); }The reaction picks a random NPC from the attack’s pool and plays it at the tuned speed:
js
function fireNpcReaction(attackFile){ if (!npcMixer || !npcReactionClips.length) return; const pool = reactionsFor(attackFile); const npcSpd = npcSpeedFor(attackFile); let chosen = null; if (pool.length){ const file = pool[Math.floor(Math.random() * pool.length)]; chosen = npcReactionClips.find(r => r.file === file); } if (!chosen){ /* fallback: any reaction */ chosen = npcReactionClips[Math.floor(Math.random() * npcReactionClips.length)]; } if (!chosen) return; if (npcIdleAction) npcIdleAction.fadeOut(0.12); npcReactionAction = npcMixer.clipAction(chosen.clip); npcReactionAction.reset(); npcReactionAction.setEffectiveTimeScale(speed * npcSpd); npcReactionAction.setLoop(THREE.LoopOnce, 1); npcReactionAction.clampWhenFinished = true; npcReactionAction.fadeIn(0.1).play(); const dur = (chosen.clip.duration || 1) * 1000 / (speed * npcSpd); reactionEndTimer = setTimeout(() => { /* fade back to idle */ npcReactionAction.fadeOut(0.2); npcIdleAction.reset().setEffectiveTimeScale(speed).fadeIn(0.2).play(); npcReactionAction = null; }, dur); }
Feature 7 β Persistence with Schema Migration
The mapping format changed four times during development. Every version bump ships with a migration function so old localStorage data never breaks.
js
const STORAGE_KEY = 'fight_viewer_mappings_v4'; function normalizeMappings(raw){ const out = {}; if (!raw || typeof raw !== 'object') return out; for (const k of Object.keys(raw)){ const v = raw[k]; if (Array.isArray(v)){ /* v1 β bare array */ out[k] = { reactions: v.slice(), range: DEFAULT_RANGE, npcSpeed: DEFAULT_NPC_SPEED, hitAt: DEFAULT_HIT_AT }; } else if (v && typeof v === 'object'){ out[k] = { reactions: Array.isArray(v.reactions) ? v.reactions.slice() : [], range: v.range ?? v.npcRange ?? DEFAULT_RANGE, npcSpeed: v.npcSpeed ?? v.speed ?? DEFAULT_NPC_SPEED, hitAt: v.hitAt ?? v.hitTiming ?? DEFAULT_HIT_AT }; } } return out; } function loadMappings(){ try { let raw = localStorage.getItem(STORAGE_KEY); if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v3'); if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v2'); if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v1'); if (!raw) return; Object.assign(mappings, normalizeMappings(JSON.parse(raw))); saveMappings(); // persist forward to v4 } catch (e){ console.warn('Bad mappings:', e); } } function saveMappings(){ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(mappings)); } catch (e){ console.warn('Save failed:', e); } }Mappings save on every change β every
+, everyΓ, every slider tick:js
function addToAttack(attackFile, npcFile){ const m = mappingFor(attackFile); if (!m.reactions.includes(npcFile)) m.reactions.push(npcFile); saveMappings(); refreshAfterChange(attackFile); } function setRangeFor(f, v){ mappingFor(f).range = v; saveMappings(); } function setNpcSpeedFor(f, v){ mappingFor(f).npcSpeed = v; saveMappings(); } function setHitAtFor(f, v){ mappingFor(f).hitAt = v; saveMappings(); }
Feature 8 β JSON Export / Import
The exported JSON is the entire contract between tool and game.
Export β cleans up defaults, so only meaningful entries appear:
js
function openIoModal(mode){ if (mode === 'export'){ const clean = {}; for (const k of Object.keys(mappings).sort()){ const m = mappings[k]; const isDefault = !m.reactions.length && m.range === DEFAULT_RANGE && m.npcSpeed === DEFAULT_NPC_SPEED && m.hitAt === DEFAULT_HIT_AT; if (isDefault) continue; clean[k] = { reactions: m.reactions, npcRange: +m.range.toFixed(3), hitAt: +m.hitAt.toFixed(3), npcSpeed: +m.npcSpeed.toFixed(3) }; } ioText.value = JSON.stringify(clean, null, 2); } }Import β validates, migrates, replaces:
js
function doApply(){ try { const parsed = JSON.parse(ioText.value); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)){ throw new Error('Not an object'); } const next = normalizeMappings(parsed); for (const k of Object.keys(mappings)) delete mappings[k]; Object.assign(mappings, next); saveMappings(); refreshAfterChange(playerClips[pairSelectedIndex]?.file || ''); if (playerClips[currentAttackIndex]) moveNpcTo(playerClips[currentAttackIndex].file); closeIoModal(); } catch (e){ ioStatus.textContent = 'β Invalid JSON: ' + e.message; } }Example output:
json
{ "flying_kick_clean.glb": { "reactions": ["NPC_knocked_out_clean.glb"], "npcRange": 1.85, "hitAt": 0.60, "npcSpeed": 0.90 }, "Headbutt_clean.glb": { "reactions": [ "NPC_receive_punch_to_the_face_clean.glb", "NPC_taking_punch_clean.glb" ], "npcRange": 0.70, "hitAt": 0.55, "npcSpeed": 1.30 } }
Feature 9 β Safe Range (No Clipping)
The minimum range for the slider is computed from the two models’ bounding boxes, using depth (Z) β the axis they face along β not width:
js
/* In loadNpc(), after both characters are loaded: */ if (playerSize && npcSize){ const pr = playerSize.z / 2; const nr = npcSize.z / 2; /* 60% safety factor β bbox is measured at bind pose */ minSeparation = (pr + nr) * 0.6; /* hard floor so the slider can't go absurdly low */ minSeparation = Math.max(minSeparation, 0.35); console.log('[range] minSeparation =', minSeparation.toFixed(3)); }Then the range slider binds its
minto that:js
const safeMin = Math.max(MIN_RANGE, minSeparation); rInput.min = safeMin; rInput.value = Math.max(range, safeMin);
What was tried and rejected: a dynamic hip-bone collision solver that pushed the NPC when the player lunged forward. It oscillated with the smooth-slide system and pushed the NPC back on attacks where the models didn’t visually overlap. Replaced with the static slider bound above β simpler, more predictable, no shake.
Full Source
The complete single-file implementation is one HTML file with four sections:
- CSSΒ β sidebar, viewport, pairing editor, sliders, export modal
- HTML skeletonΒ β the DOM structure
- importmapΒ β three.js from unpkg
- ScriptΒ β everything above, plus the scene setup, loaders, and render loop
The full file is ~1200 lines. Key extras beyond the features above:
- Three.js scene withΒ
RoomEnvironmentΒ for IBL lighting GLTFLoaderΒ +ΒDRACOLoaderΒ for compressed meshesAnimationMixerΒ per character with fade-based blendingOrbitControlsΒ for cameraanimate()Β render loop driving both mixers and the NPC’s smooth slide- Distance guide (a dashed line from player to NPC) shown while dragging the range slider
The critical wiring between them is minimal:
js
/* Smooth NPC slide */ if (npcRoot){ const diff = npcTargetZ - npcCurrentZ; if (Math.abs(diff) > 0.0005){ npcCurrentZ += diff * Math.min(1, dt * 10); npcRoot.position.z = npcCurrentZ; } else if (npcCurrentZ !== npcTargetZ){ npcCurrentZ = npcTargetZ; npcRoot.position.z = npcTargetZ; } }That single lerp is what makes all per-attack distance changes feel animated instead of snappy.
Integration with Game Runtime
The tool’s output is a plain JSON file. In your game:
js
const pairings = await fetch('fight_pairings.json').then(r => r.json()); function onPlayerAttack(attackFile, attackClip){ const entry = pairings[attackFile]; if (!entry) return; /* 1. Position the NPC at the tuned distance */ npc.position.z = entry.npcRange; /* 2. Schedule the reaction at the tuned hit frame */ const hitTimeMs = attackClip.duration * entry.hitAt * 1000; setTimeout(() => { /* 3. Pick a random reaction from the pool */ const reactionFile = entry.reactions[ Math.floor(Math.random() * entry.reactions.length) ]; const reactionClip = reactionClipMap[reactionFile]; if (!reactionClip) return; /* 4. Play it at the tuned speed */ const action = npcMixer.clipAction(reactionClip); action.setEffectiveTimeScale(entry.npcSpeed); action.setLoop(THREE.LoopOnce, 1); action.clampWhenFinished = true; action.play(); /* 5. Fade back to idle when it ends */ const dur = reactionClip.duration * 1000 / entry.npcSpeed; setTimeout(() => { action.fadeOut(0.2); npcIdleAction.reset().setEffectiveTimeScale(1).fadeIn(0.2).play(); }, dur); }, hitTimeMs); }The game doesn’t need to know about sliders, previews, or localStorage. It just reads numbers and applies them.
Key Takeaways
1. The viewport is sacred. Never cover it. Bottom pane > modal.
2. Auto-preview on every slider release. Sub-100 ms feedback loops turn a config tool into a toy.
3. Bump storage schemas with migration functions. Never break saved data. Ship v4, support v1 through v4.
4. Don’t over-solve for correctness. A static minimum on a slider beats a dynamic collision solver β simpler, more predictable, designer-friendly.
5. Measure the right axis.
size.xfor side-by-side,size.zfor face-to-face. Bounding boxes at bind pose lie.6. JSON is the contract. The tool and the game should communicate through data, not code.
7. Ship with fallbacks. Server behavior is a variable you don’t control. A hardcoded file list costs 20 lines and saves hours.
The tool exists to answer one question per attack: “When the player does X, what does the NPC do, where, and how fast?” Everything else is scaffolding.
Tags: three.js, game dev, animation, tooling, web, combat systems, JSON, localStorage.
-
Optimization of Kinetic Continuity in 3D Parkour Environments: Swing and Landing Mechanics
In game development, “feel” is everything. When building a parkour engine, movement must feel continuous. If a player performs an action and the character suddenly “snaps” or “teleports” from one height to another, the immersion is shattered.
Recently, while developing a parkour prototype using Three.js, I encountered a critical failure in the Swing Mechanic. This article documents the evolution of that feature: a journey from a rigid, ground-only movement to a fully realized, height-aware physics system.
Phase 1: Solving Input Rigidity
Initially, the swing mechanic was functionally primitive. The system was logically bound to a single state: the player had to be standing on the floor to interact with the environment.
The Problem: State-Dependent Input
The input handler restricted the KeyF (Swing) action inside a conditional block that only checked if the player’s state was ‘ground’. This created a “dead zone” where jumping or falling made the mechanic unreachable.
The Problematic Code:
code JavaScript
// β THE PROBLEM: Input is locked to the 'ground' state function onPress(code) { if (state === 'ground') { switch (code) { case 'KeyE': tryHang(); break; case 'KeyF': trySwing(); break; // Logic fails if state is 'air' } } }The Fix: Universal Input Triggering
To achieve true parkour flow, a player must be able to grab a bar mid-air. I refactored the input logic to decouple the swing trigger from the grounded state.
The Optimized Code:
code JavaScript
// β THE FIX: Decoupling input from the 'ground' state function onPress(code) { if (state === 'ground') { /* ...ground-only actions... */ } // The swing trigger is now state-agnostic if (code === 'KeyF') { trySwing(); // Works during 'ground' OR 'air' states } }
Phase 2: The “Teleportation Pop” Bug
Once the player could grab the bar from the air, a much more jarring issue emerged: Kinematic Discontinuity. If a player jumped from a high building and grabbed a bar, they would instantly “teleport” to the floor the moment they touched the bar.
The Problem: Hard-Coded Coordinate Snapping
The bug originated in the trySwing function. Upon engagement, the system was hard-coding the player’s vertical position (Y_pos) to 0 (the ground plane).
The Problematic Code:
code JavaScript
// β THE PROBLEM: Hard-coded ground snap function trySwing() { // ... logic to find bar ... // This '0' forced the player to the floor instantly. pos.set(targetX, 0, targetZ); state = 'swing'; }The resulting visual “jitter” followed this sequence:
- Grab: Player engages the bar mid-air.
- Teleport: Character instantly snaps to
Y=0Y=0(the ground). - Correction: The physics engine realizes the player is actually above a block.
- Pop: The character is violently “yanked” back up to the block.
Phase 3: The Advanced Physics Solution
To achieve a professional “feel,” I implemented a three-tier optimization strategy: Height Memory, Sinusoidal Interpolation, and Smart Landing.
Step 1: Temporal Height Memory
We moved away from hard-coded values by implementing Height Memory. The system now captures the player’s exact vertical position (Y_start) at the moment of engagement.
The Optimized Code:
code JavaScript
// β Inside trySwing() swingStartY = pos.y; // Capture the exact height of the jump/fall // Snap to the BAR height, not the floor, preserving verticality pos.set(targetX, bar.y, targetZ);Step 2: Sinusoidal Path Interpolation
A natural swing follows an arc, not a straight line. I used Linear Interpolation (LERP) to transition between the starting height (Y_start) and the landing height (Y_land), combined with a Sine Wave to simulate gravitational momentum.
The Optimized Code:
code JavaScript
// β THE FIX: Smooth height interpolation via Sinusoidal Arc function updateSwing() { const t = clamp(activeAction.time / duration, 0, 1); const u = t * t * (3 - 2 * t); // Smoothstep easing // LERP: Move from captured start height to predicted landing height const heightTransition = THREE.MathUtils.lerp(swingStartY, swingLandY, u); // Sinusoidal Arc: Adds a "swing boost" to create a physical curve const arcHeight = Math.max(swingStartY, swingLandY, 1.0) * 0.5; pos.y = heightTransition + arcHeight * Math.sin(Math.PI * u); }Step 3: Smart Landing (Conditional State Switching)
Finally, we addressed the “end-of-animation” transition. If the swing ends over a platform, we snap to it. If the swing ends in mid-air, we must avoid snapping to the ground and instead transition the player into a natural falling state.
The Optimized Code:
code JavaScript
// β THE FIX: Conditional landing logic function finishSwing() { if (swingLandY > 0) { // Scenario A: Surface detected -> Transition to Grounded pos.x = swingTargetPos.x; pos.y = swingLandY; state = 'ground'; } else { // Scenario B: No surface detected -> Transition to Airborne (Natural Fall) state = 'air'; airMode = 'fall'; } }
Conclusion
By moving away from hard-coded coordinate snapping and embracing a State-Based Physics model, we transformed a glitchy, broken mechanic into a fluid movement system.
Key Takeaway for Developers: In any physics-based engine, always respect the player’s current momentum and position. Never force a coordinate unless you have mathematically accounted for the transition from the previous state.
Keywords: Three.js, Game Development, Physics Engine, JavaScript, Parkour Game, Bug Fixing, Lerp, Kinematics, Animation.