Category: Parkour

  • 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

    FeatureWhat it does
    Auto-discovery + fallbackLoads GLBs from folder listing or a hardcoded list
    Auto-classificationFiles starting with NPC_ are NPC assets; everything else is a player attack
    Blender-style editorBottom split-pane that keeps the 3D view visible
    Live previewsClick β–Ά on any row to play that clip on the 3D scene
    Per-attack slidersRange, hit timing, and NPC speed β€” all saved per attack
    Auto-preview on releaseReleasing a slider plays the result instantly
    localStorage persistencePairings save automatically, survive refreshes
    Schema migrationOld saved data auto-upgrades to newer formats
    JSON export / importThe config contract between tool and game
    Static minimum rangeSlider 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.server generates) exposes <a href="file.glb"> links we can scrape. The problem: as soon as you add an index.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.glb vs foo_clean.glb β€” keeping only the _clean one:

    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 pool
    • NPC_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:

    SliderRangeMeaning
    ↔ Range0.35 – 4.0 mWhere the NPC stands
    🎯 Hit At5% – 95%When the reaction fires (fraction of clip)
    ⏱ Speed0.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 min to 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:

    1. CSSΒ β€” sidebar, viewport, pairing editor, sliders, export modal
    2. HTML skeletonΒ β€” the DOM structure
    3. importmapΒ β€” three.js from unpkg
    4. 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 meshes
    • AnimationMixerΒ per character with fade-based blending
    • OrbitControlsΒ for camera
    • animate()Β 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.x for side-by-side, size.z for 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:

    1. Grab: Player engages the bar mid-air.
    2. Teleport: Character instantly snaps to Y=0Y=0 (the ground).
    3. Correction: The physics engine realizes the player is actually above a block.
    4. 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.