Category: AI

  • Complete Guide: Build a Private AI Grammar Fixer App on Debian

    Running an AI locally keeps your data 100% private, works offline, and costs absolutely nothing. This step-by-step guide covers how to install Ollama, download Meta’s Llama 3.2, and create a one-click desktop app that instantly fixes your grammar.


    Part 1: Install Ollama and Llama 3.2

    If you are starting on a fresh Debian system, open your terminal and run these steps to get the AI engine ready:

    1. Install the Ollama Engine

    Download and install the official Ollama environment using this single command:

    bash

    curl -fsSL https://ollama.com | sh
    

    Use code with caution.

    2. Download the Llama 3.2 Model

    Pull Meta’s lightweight Llama 3.2 (3B) model to your computer:

    bash

    ollama pull llama3.2
    

    Use code with caution.

    To verify that the model downloaded successfully, you can run ollama list.


    Part 2: Create Your Automated Grammar App

    By default, Llama wants to converse casually. We will build a customized profile called a Modelfile to restrict it to only fixing grammar, then wrap it in a clickable shortcut app.

    1. Configure the Automated Rules

    Run this block to write the configuration profile that tells the AI how to behave:

    bash

    cat << 'EOF' > ~/Modelfile
    FROM llama3.2
    SYSTEM You are an expert grammar corrector. Your only job is to correct the grammar and spelling of the text the user pastes. Reply ONLY with the corrected version. Do not explain the changes. Do not say "Here is the corrected text". Just output the clean text.
    EOF
    

    Use code with caution.

    2. Compile Your Dedicated Model

    Build a custom model variant named grammar using the profile rules we just wrote:

    bash

    ollama create grammar -f ~/Modelfile
    

    Use code with caution.

    3. Make the One-Click Desktop Application

    Run this final block to create a launch shortcut on your desktop, make it clickable, and add it directly to your system’s main application menu:

    bash

    cat << 'EOF' > ~/Desktop/llama-grammar.desktop
    [Desktop Entry]
    Version=1.0
    Type=Application
    Name=AI Grammar Fixer
    Comment=Fixes your grammar instantly using Llama 3.2
    Exec=ollama run grammar
    Terminal=true
    Icon=accessories-text-editor
    Categories=Utility;
    EOF
    
    # Make the icon launcher clickable
    chmod +x ~/Desktop/llama-grammar.desktop
    
    # Sync it to your system app overview menu
    cp ~/Desktop/llama-grammar.desktop ~/.local/share/applications/
    

    Use code with caution.


    How to Use Your New App

    1. Open your Debian Applications Menu or Activities Overview.
    2. Search for “AI Grammar Fixer”.
    3. Optional: Right-click the icon and choose “Add to Favorites” to pin it to your desktop panel or dock.
    4. Click the app. A clean terminal window will instantly pop open displaying the >>> prompt.
    5. Paste your broken text (using Ctrl + Shift + V or right-click) and hit Enter.

    The local AI will immediately output the perfectly corrected grammar without any extra commentary! When you are finished, just close the window.

  • 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.

  • parkour three.js base on battle

    Kachujin G Rosales – mixamo.com

    https://officialstupid.com/parkour/

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Parkour</title>
        <style>
            body { margin: 0; overflow: hidden; background-color: #000; font-family: 'Arial Black', Arial, sans-serif; }
            #ui {
                position: absolute; top: 16px; left: 16px; z-index: 10;
                color: #fff; padding: 12px 14px; text-transform: uppercase;
                font-size: 11px; line-height: 1.6;
                background: rgba(0, 0, 0, 0.35); border-radius: 8px; pointer-events: none;
            }
            #ui h2 { margin: 0 0 8px 0; font-size: 16px; }
            .key-bind { color: #ffeb3b; font-weight: bold; }
            .hint { color: #9aa; margin-top: 8px; font-size: 10px; }
            #prompt {
                position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%);
                color: #fff; background: rgba(0, 0, 0, 0.55); padding: 8px 16px; border-radius: 8px;
                font-size: 14px; text-transform: uppercase; z-index: 10; display: none; pointer-events: none;
            }
            #status {
                position: absolute; bottom: 16px; left: 16px; color: #8f8; font-size: 12px;
                text-transform: uppercase; z-index: 10; pointer-events: none;
            }
            #loading {
                position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
                color: #fff; font-size: 20px; text-transform: uppercase; z-index: 20; background: #111;
                text-align: center; padding: 20px;
            }
            #editBtn {
                position: absolute; top: 16px; right: 16px; z-index: 30;
                background: #ffeb3b; color: #000; border: 0; border-radius: 8px;
                padding: 10px 16px; font: 13px 'Arial Black', Arial, sans-serif; cursor: pointer;
                text-transform: uppercase;
            }
            #editBtn:hover { background: #fff59d; }
            #editPanel {
                display: none; position: absolute; top: 62px; right: 16px; width: 230px; z-index: 30;
                background: rgba(0, 0, 0, 0.75); color: #fff; border-radius: 8px; padding: 12px;
                font: 11px Arial, sans-serif; text-transform: uppercase;
            }
            #editPanel #editTitle { font: 12px 'Arial Black', Arial, sans-serif; margin-bottom: 8px; color: #ffeb3b; }
            #editPanel .row { display: flex; justify-content: space-between; align-items: center; margin: 4px 0; }
            #editPanel input[type=number] {
                width: 90px; background: #222; color: #fff; border: 1px solid #555; border-radius: 4px; padding: 4px;
            }
            #editPanel .btns { display: flex; gap: 6px; margin-top: 8px; }
            #editPanel button {
                flex: 1; background: #333; color: #fff; border: 1px solid #666; border-radius: 4px;
                padding: 6px 4px; cursor: pointer; font: 11px Arial, sans-serif; text-transform: uppercase;
            }
            #editPanel button:hover:not(:disabled) { background: #555; }
            #editPanel button:disabled { opacity: 0.4; cursor: default; }
            #editPanel .hint { margin-top: 8px; color: #9aa; font-size: 10px; text-transform: none; }
        </style>
    </head>
    <body>
    
        <div id="loading">Loading...</div>
    
        <div id="ui">
            <h2>Parkour</h2>
            <div><span class="key-bind">W A S D :</span> Move (sneak walk)</div>
            <div><span class="key-bind">Shift :</span> Sprint</div>
            <div><span class="key-bind">C (hold) :</span> Low crawl</div>
            <div><span class="key-bind">Space :</span> Jump</div>
            <div><span class="key-bind">V :</span> Slide</div>
            <div><span class="key-bind">R :</span> Roll</div>
            <div><span class="key-bind">E :</span> Ledge hang (stand near edge of green tower)</div>
            <div><span class="key-bind">  A :</span> Hop left   <span class="key-bind">S / Space :</span> Drop</div>
            <div><span class="key-bind">F :</span> Grab / release swing bar</div>
            <div><span class="key-bind">Mouse drag :</span> Orbit camera   <span class="key-bind">Wheel :</span> Zoom</div>
            <div class="hint">Jump off the 4m tower for a hard landing</div>
            <div class="hint">Edit button (top right) = resize / move / clone objects</div>
        </div>
        <div id="prompt"></div>
    
        <button id="editBtn">✎ Edit</button>
        <div id="editPanel">
            <div id="editTitle">Click an object to select</div>
            <div class="row"><label>Position X</label><input id="f-x" type="number" step="0.1"></div>
            <div class="row"><label>Position Z</label><input id="f-z" type="number" step="0.1"></div>
            <div class="row"><label>Lift from ground</label><input id="f-y" type="number" step="0.1"></div>
            <div class="row"><label>Width (X)</label><input id="f-w" type="number" step="0.1" min="0.2"></div>
            <div class="row"><label>Height</label><input id="f-h" type="number" step="0.1" min="0.1"></div>
            <div class="row"><label>Depth (Z)</label><input id="f-d" type="number" step="0.1" min="0.2"></div>
            <div class="row"><label for="f-ledge">Hangable ledge</label><input id="f-ledge" type="checkbox"></div>
            <div class="btns"><button id="btnSmaller">Size -10%</button><button id="btnBigger">Size +10%</button></div>
            <div class="btns"><button id="btnClone">Clone</button><button id="btnDelete">Delete</button></div>
            <div class="btns"><button id="btnNew">+ New box</button><button id="btnReset">Reset all</button></div>
            <div class="hint">Drag a selected object to move it. Esc = deselect, Delete = remove. Layout is saved automatically.</div>
        </div>
        <div id="status"></div>
    
        <script type="importmap">
            {
                "imports": {
                    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
                }
            }
        </script>
    
        <script type="module">
            import * as THREE from 'three';
            import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
            import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
    
            /* =====================================================================
               CONFIG
            ===================================================================== */
            const ASSET_PATH = './';
    
            const CLIP_FILES = {
                sneak:     'sneak_walk_clean.glb',
                sprint:    'Sprint_clean.glb',
                crawl:     'low_crawl_clean.glb',
                jump:      'Jumping_clean.glb',
                fall:      'jumping_down_clean.glb',
                hardLand:  'hard_landing_clean.glb',
                slide:     'running_slide_clean.glb',
                roll:      'run_to_rolling_clean.glb',
                hangDrop:  'braced_hang_drop_clean.glb',
                hangHop:   'braced_hang_hop_left_clean.glb',
                swing:     'Swinging_clean.glb',
                swingLand: 'swing_to_land_clean.glb'
            };
    
            // inplace : remove sideways/forward hip drift, keep hip height (code moves the character)
            // physics : remove hip drift AND hip height (code handles gravity / jumping)
            // raw     : keep everything (hang / swing clips must stay attached to ledge / bar)
            const ROOT_MODE = {
                sneak: 'inplace', sprint: 'inplace', crawl: 'inplace', slide: 'inplace',
                roll: 'inplace', hardLand: 'inplace',
                jump: 'physics', fall: 'physics',
                hangDrop: 'raw', hangHop: 'raw', swing: 'raw', swingLand: 'raw'
            };
            const LOOPING = new Set(['sneak', 'sprint', 'crawl', 'swing']);
    
            // per-clip playback speed multipliers (tweak to fix foot sliding)
            const ANIM_SPEED = {
                sneak: 1, sprint: 1, crawl: 1, slide: 1, roll: 1, hardLand: 1,
                fall: 1, hangDrop: 1, hangHop: 1, swing: 1, swingLand: 1
            };
            const GLOBAL_ANIMATION_SPEED = 1.0;
    
            const MODEL_SCALE = 1;
            const MODEL_YAW_OFFSET = 0;     // set to Math.PI if the character walks backwards
    
            // movement
            const SNEAK_SPEED  = 1.6;
            const SPRINT_SPEED = 6.0;
            const CRAWL_SPEED  = 1.0;
            const SLIDE_SPEED  = 7.0;
            const ROLL_SPEED   = 5.0;
    
            // physics
            const GRAVITY    = 22;
            const JUMP_V     = 7.5;
            const HARD_LAND_V = 11;         // landing speed that triggers hard landing (~2.75m fall)
            const JUMP_TAKEOFF_FRAC = 0.3;  // fraction of the Jumping clip where feet leave the ground
            const FALL_START_FRAC = 0.25;   // where in jumping_down we start when falling
            const FALL_HOLD_FRAC  = 0.5;    // where in jumping_down we freeze the pose while falling
    
            // body
            const PLAYER_R = 0.35;
            const STAND_H = 1.8, LOW_H = 0.7, ROLL_H = 0.8;
            const STEP = 0.3;               // auto step-up height
            const SNAP_DOWN = 0.9;          // drops smaller than this are walked down without falling
    
            const EDGE_GRAB = 1.0;          // how close to a ledge edge you must stand
            const BAR_REACH = 1.8;
            const WORLD_LIMIT = 90;
            const TURN_SPEED = 14;
    
            /* =====================================================================
               STATE
            ===================================================================== */
            let scene, camera, renderer, clock, mixer, dirLight;
            const characterGroup = new THREE.Group();
            const pos = characterGroup.position;
            let model = null;
    
            const actions = {};
            const baseScale = {};
            let activeAction = null;
            let activeName = '';
    
            let ready = false;
            let keys = {};
    
            // character state machine: ground | air | locked | hang | swing
            let state = 'ground';
            let lockType = '';
            let lockHeight = STAND_H;
            let hangSub = '';       // enter | hold | hop
            let swingSub = '';      // hold | land
            let airMode = 'jump';   // jump | fall
            let pendingJump = false;
            let jumpTimer = 0;
            let jumpDelay = 0.15;
            let vy = 0;
            let airSpeedCap = SNEAK_SPEED;
            let crawling = false;
            const velH = new THREE.Vector3();
            let facingAngle = 0, targetAngle = 0;
            let hangCtx = null;
            let pendingDelta = { x: 0, z: 0 };
            let bar = null;
    
            // measured poses (filled after load)
            let idlePose, hangEnd, hopEnd, swingStart, swingLandEnd;
            let hipsBone = null;
            const handBones = [];
    
            // level
            const boxes = [];
    
            // edit mode
            const LAYOUT_KEY = 'parkourLayout_v1';
            let editMode = false;
            let selected = null;
            let boxDragging = false;
            let moved = false;
            let dragOffX = 0, dragOffZ = 0;
            let saveTimer = null;
            const raycaster = new THREE.Raycaster();
            const ndc = new THREE.Vector2();
            const dragPlane = new THREE.Plane();
            const dragPoint = new THREE.Vector3();
    
            // orbit camera
            let camYaw = Math.PI, camPitch = 0.35, camDist = 6;
            const camTarget = new THREE.Vector3(0, 1.2, 0);
            let dragging = false;
    
            const _fwd = new THREE.Vector3();
            const _right = new THREE.Vector3();
            const _move = new THREE.Vector3();
    
            const statusEl = document.getElementById('status');
            const promptEl = document.getElementById('prompt');
            const loadingEl = document.getElementById('loading');
            let lastPrompt = '', lastStatus = '';
    
            /* =====================================================================
               HELPERS
            ===================================================================== */
            const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
    
            function l2w(lx, lz, yaw) {
                const c = Math.cos(yaw), s = Math.sin(yaw);
                return { x: lx * c + lz * s, z: -lx * s + lz * c };
            }
    
            function shortestAngleLerp(a, b, t) {
                const d = ((b - a + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
                return a + d * Math.min(t, 1);
            }
    
            function setLoading(msg) { if (loadingEl) loadingEl.textContent = msg; }
    
            /* =====================================================================
               LEVEL
            ===================================================================== */
            function addBox(x0, x1, z0, z1, bottom, top, color, opts = {}) {
                const w = x1 - x0, d = z1 - z0, h = top - bottom;
                const geo = new THREE.BoxGeometry(w, h, d);
                const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color }));
                mesh.position.set((x0 + x1) / 2, bottom + h / 2, (z0 + z1) / 2);
                mesh.castShadow = true;
                mesh.receiveShadow = true;
                scene.add(mesh);
    
                const edges = new THREE.LineSegments(
                    new THREE.EdgesGeometry(geo),
                    new THREE.LineBasicMaterial({ color: opts.ledge ? 0xffb74d : 0x000000, transparent: true, opacity: 0.55 })
                );
                edges.position.copy(mesh.position);
                scene.add(edges);
    
                const rec = { minX: x0, maxX: x1, minZ: z0, maxZ: z1, bottom, top, ledge: !!opts.ledge, color, mesh, edges };
                mesh.userData.box = rec;
                boxes.push(rec);
                return rec;
            }
    
            function buildLevel() {
                // staircase of jumpable steps -> tall ledge tower (4m)
                addBox(5, 8, -3, 3, 0, 1, 0x3b5b7a);
                addBox(8, 11, -3, 3, 0, 2, 0x3f6a8f);
                addBox(11, 14, -3, 3, 0, 3, 0x4478a3);
                addBox(14, 20, -4, 4, 0, 4, 0x2f8f6f, { ledge: true });
    
                // second ledge platform (3m) with its own steps
                addBox(-17, -15, 11, 15, 0, 1, 0x5a5a8a);
                addBox(-19, -17, 11, 15, 0, 2, 0x5f5f95);
                addBox(-25, -19, 10, 16, 0, 3, 0x2f8f6f, { ledge: true });
    
                // crates to jump over / onto
                addBox(-3, -1.5, -5, -3.5, 0, 0.6, 0x7a5b3b);
                addBox(1.5, 3, -6, -4.5, 0, 1.0, 0x8a6b3b);
    
                // low beam to slide / roll under (walk is blocked, jump over also works)
                addBox(-4, 4, 6, 6.4, 0.85, 1.0, 0xb04a4a);
                addBox(-4.4, -4, 6, 6.4, 0, 1.0, 0x803030);
                addBox(4, 4.4, 6, 6.4, 0, 1.0, 0x803030);
    
                // crawl tunnel
                addBox(-14, -7, -1.9, 1.9, 0.85, 1.0, 0x7a4aa8);
                addBox(-14, -7, -1.9, -1.6, 0, 1.0, 0x5a3a88);
                addBox(-14, -7, 1.6, 1.9, 0, 1.0, 0x5a3a88);
    
                // scattered pillars
                const pillarCol = 0x444466;
                for (let i = 0; i < 16; i++) {
                    const a = (i / 16) * Math.PI * 2 + 0.3;
                    const r = 36 + (i % 4) * 7;
                    const h = 1.5 + (i % 5) * 0.8;
                    const x = Math.cos(a) * r, z = Math.sin(a) * r;
                    addBox(x - 0.6, x + 0.6, z - 0.6, z + 0.6, 0, h, pillarCol);
                }
            }
    
            function buildSwingBar(barY) {
                const bx = 0, bz = -10, half = 3;
                const mat = new THREE.MeshPhongMaterial({ color: 0xcfd8dc });
                const rail = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, half * 2 + 0.6, 12), mat);
                rail.rotation.z = Math.PI / 2;
                rail.position.set(bx, barY, bz);
                rail.castShadow = true;
                scene.add(rail);
                for (const s of [-1, 1]) {
                    const post = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, barY + 0.1, 12), mat);
                    post.position.set(bx + s * (half + 0.3), (barY + 0.1) / 2, bz);
                    post.castShadow = true;
                    scene.add(post);
                }
                bar = { x: bx, z: bz, half, y: barY };
            }
    
            function getGroundHeight(x, z, y) {
                let h = 0;
                for (const b of boxes) {
                    if (x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ && b.top <= y + STEP && b.top > h) h = b.top;
                }
                return h;
            }
    
            function circleHitsBox(b, r) {
                const cx = clamp(pos.x, b.minX, b.maxX), cz = clamp(pos.z, b.minZ, b.maxZ);
                const dx = pos.x - cx, dz = pos.z - cz;
                return dx * dx + dz * dz < r * r;
            }
    
            function resolveCollisions(h) {
                for (const b of boxes) {
                    if (!(pos.y + h > b.bottom && pos.y + STEP < b.top)) continue;
                    const cx = clamp(pos.x, b.minX, b.maxX), cz = clamp(pos.z, b.minZ, b.maxZ);
                    const dx = pos.x - cx, dz = pos.z - cz;
                    const d2 = dx * dx + dz * dz;
                    if (d2 < PLAYER_R * PLAYER_R) {
                        if (d2 > 1e-8) {
                            const d = Math.sqrt(d2);
                            pos.x = cx + dx / d * PLAYER_R;
                            pos.z = cz + dz / d * PLAYER_R;
                        } else {
                            const l = pos.x - b.minX, r = b.maxX - pos.x, f = pos.z - b.minZ, k = b.maxZ - pos.z;
                            const m = Math.min(l, r, f, k);
                            if (m === l) pos.x = b.minX - PLAYER_R;
                            else if (m === r) pos.x = b.maxX + PLAYER_R;
                            else if (m === f) pos.z = b.minZ - PLAYER_R;
                            else pos.z = b.maxZ + PLAYER_R;
                        }
                    }
                }
                pos.x = clamp(pos.x, -WORLD_LIMIT, WORLD_LIMIT);
                pos.z = clamp(pos.z, -WORLD_LIMIT, WORLD_LIMIT);
            }
    
            function headroomBlocked() {
                for (const b of boxes) {
                    if (b.bottom <= 0) continue;
                    if (b.bottom < pos.y + STAND_H && b.top > pos.y + LOW_H && circleHitsBox(b, PLAYER_R + 0.05)) return true;
                }
                return false;
            }
    
            /* =====================================================================
               INIT
            ===================================================================== */
            init();
    
            async function init() {
                scene = new THREE.Scene();
                scene.background = new THREE.Color(0x111111);
                scene.fog = new THREE.Fog(0x111111, 40, 140);
                clock = new THREE.Clock();
    
                camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
    
                renderer = new THREE.WebGLRenderer({ antialias: true });
                renderer.setSize(window.innerWidth, window.innerHeight);
                renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
                renderer.shadowMap.enabled = true;
                document.body.appendChild(renderer.domElement);
    
                scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 3));
                dirLight = new THREE.DirectionalLight(0xffffff, 2);
                dirLight.position.set(5, 10, 5);
                dirLight.castShadow = true;
                dirLight.shadow.camera.left = -20;
                dirLight.shadow.camera.right = 20;
                dirLight.shadow.camera.top = 20;
                dirLight.shadow.camera.bottom = -20;
                dirLight.shadow.camera.near = 0.5;
                dirLight.shadow.camera.far = 60;
                dirLight.shadow.mapSize.set(2048, 2048);
                scene.add(dirLight);
                scene.add(dirLight.target);
    
                const size = WORLD_LIMIT * 2 + 20;
                const floor = new THREE.Mesh(new THREE.PlaneGeometry(size, size), new THREE.MeshPhongMaterial({ color: 0x222222 }));
                floor.rotation.x = -Math.PI / 2;
                floor.receiveShadow = true;
                scene.add(floor);
                const grid = new THREE.GridHelper(size, size / 2, 0x555555, 0x333333);
                grid.position.y = 0.01;
                scene.add(grid);
    
                loadLayoutOrDefault();
                scene.add(characterGroup);
    
                setupInput();
                setupEditUI();
                window.addEventListener('resize', onResize);
                animate();
    
                await loadCharacter();
            }
    
            function setupInput() {
                window.addEventListener('keydown', (e) => {
                    const tag = (e.target && e.target.tagName) || '';
                    if (/INPUT|TEXTAREA|SELECT/.test(tag)) return;   // typing in the edit panel
                    if (editMode && e.code === 'Escape') { selectBox(null); return; }
                    if (editMode && selected && (e.code === 'Delete' || e.code === 'Backspace')) { deleteSelected(); return; }
                    if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
                    if (e.repeat) return;
                    keys[e.code] = true;
                    onPress(e.code);
                });
                window.addEventListener('keyup', (e) => { keys[e.code] = false; });
                window.addEventListener('blur', () => { keys = {}; });
    
                const el = renderer.domElement;
                el.addEventListener('contextmenu', e => e.preventDefault());
                let downX = 0, downY = 0;
                el.addEventListener('pointerdown', (e) => {
                    downX = e.clientX; downY = e.clientY; moved = false;
                    el.setPointerCapture(e.pointerId);
                    if (editMode) {
                        const hit = pickBox(e);
                        if (hit) {
                            selectBox(hit.box);
                            dragPlane.set(new THREE.Vector3(0, 1, 0), -hit.point.y);
                            dragOffX = (hit.box.minX + hit.box.maxX) / 2 - hit.point.x;
                            dragOffZ = (hit.box.minZ + hit.box.maxZ) / 2 - hit.point.z;
                            boxDragging = true;
                            return;
                        }
                    }
                    dragging = true;
                });
                el.addEventListener('pointerup', (e) => {
                    if (boxDragging) {
                        boxDragging = false;
                        saveLayoutSoon();
                    } else if (dragging && editMode && !moved && !pickBox(e)) {
                        selectBox(null);
                    }
                    dragging = false;
                    if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
                });
                el.addEventListener('pointermove', (e) => {
                    if (Math.hypot(e.clientX - downX, e.clientY - downY) > 4) moved = true;
                    if (boxDragging && selected) {
                        setRay(e);
                        if (raycaster.ray.intersectPlane(dragPlane, dragPoint)) {
                            moveSelectedTo(
                                Math.round((dragPoint.x + dragOffX) / 0.05) * 0.05,
                                Math.round((dragPoint.z + dragOffZ) / 0.05) * 0.05
                            );
                        }
                        return;
                    }
                    if (!dragging) return;
                    camYaw -= e.movementX * 0.005;
                    camPitch = clamp(camPitch + e.movementY * 0.005, 0.05, 1.4);
                });
                el.addEventListener('wheel', (e) => {
                    camDist = clamp(camDist + e.deltaY * 0.005, 2, 14);
                }, { passive: true });
            }
    
            function onResize() {
                camera.aspect = window.innerWidth / window.innerHeight;
                camera.updateProjectionMatrix();
                renderer.setSize(window.innerWidth, window.innerHeight);
            }
    
            /* =====================================================================
               CHARACTER + ANIMATION LOADING
            ===================================================================== */
            function pickClip(gltf) {
                let best = null;
                for (const c of gltf.animations) if (!best || c.duration > best.duration) best = c;
                return best;
            }
    
            function processClip(clip, mode) {
                const posTracks = clip.tracks.filter(t => t.name.endsWith('.position'));
                let tracks = posTracks.filter(t => /hips|pelvis|root/i.test(t.name));
                if (!tracks.length) tracks = posTracks.slice(0, 1);
                for (const t of tracks) {
                    const v = t.values;
                    for (let i = 0; i < v.length; i += 3) {
                        if (mode === 'inplace' || mode === 'physics') { v[i] = v[0]; v[i + 2] = v[2]; }
                        if (mode === 'physics') v[i + 1] = v[1];
                    }
                }
            }
    
            function samplePose(name, t) {
                const a = actions[name];
                const out = { hips: new THREE.Vector3(0, 1, 0), hand: new THREE.Vector3(0, 1.5, 0.3) };
                if (!a) return out;
                mixer.stopAllAction();
                a.reset();
                a.setEffectiveWeight(1);
                a.setEffectiveTimeScale(1);
                a.play();
                a.time = (t === 'end') ? a.getClip().duration : t;
                mixer.update(0);
                characterGroup.updateMatrixWorld(true);
    
                (hipsBone || model).getWorldPosition(out.hips);
                if (handBones.length) {
                    out.hand.set(0, 0, 0);
                    const p = new THREE.Vector3();
                    for (const b of handBones) { b.getWorldPosition(p); out.hand.add(p); }
                    out.hand.divideScalar(handBones.length);
                } else {
                    out.hand.copy(out.hips);
                }
                a.stop();
                return out;
            }
    
            async function loadCharacter() {
                const dracoLoader = new DRACOLoader();
                dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/');
                const loader = new GLTFLoader();
                loader.setDRACOLoader(dracoLoader);
    
                const names = Object.keys(CLIP_FILES);
                const gltfs = {};
                let done = 0;
                setLoading(`Loading 0 / ${names.length}`);
    
                await Promise.all(names.map(async (k) => {
                    try {
                        gltfs[k] = await loader.loadAsync(ASSET_PATH + CLIP_FILES[k]);
                    } catch (err) {
                        console.error('Failed to load', CLIP_FILES[k], err);
                    }
                    done++;
                    setLoading(`Loading ${done} / ${names.length}`);
                }));
    
                const base = gltfs.sneak || Object.values(gltfs)[0];
                if (!base) {
                    setLoading('No GLB files found. Put index.html next to the _clean.glb files and open it through a local server (python3 -m http.server).');
                    return;
                }
    
                model = base.scene;
                model.scale.setScalar(MODEL_SCALE);
                model.traverse(o => {
                    if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; }
                    if (o.isBone) {
                        if (!hipsBone && /hips$/i.test(o.name)) hipsBone = o;
                        if (/(left|right)hand$/i.test(o.name)) handBones.push(o);
                    }
                });
                characterGroup.add(model);
                mixer = new THREE.AnimationMixer(model);
    
                // build actions
                for (const k of names) {
                    const g = gltfs[k];
                    if (!g || !g.animations.length) continue;
                    const clip = pickClip(g);
                    clip.name = k;
                    processClip(clip, ROOT_MODE[k]);
                    const action = mixer.clipAction(clip);
                    if (LOOPING.has(k)) {
                        action.setLoop(THREE.LoopRepeat);
                    } else {
                        action.setLoop(THREE.LoopOnce);
                        action.clampWhenFinished = true;
                    }
                    actions[k] = action;
                    baseScale[k] = GLOBAL_ANIMATION_SPEED * (ANIM_SPEED[k] ?? 1);
                }
    
                // idle = first frame of sneak walk, frozen
                const idleSrc = actions.sneak || Object.values(actions)[0];
                if (!idleSrc) { setLoading('Files loaded but no animations found inside them.'); return; }
                const idleClip = idleSrc.getClip().clone();
                idleClip.name = 'idle';
                actions.idle = mixer.clipAction(idleClip);
                actions.idle.setLoop(THREE.LoopRepeat);
                baseScale.idle = 0;
    
                // frozen last frame of the hang drop = hanging pose
                if (actions.hangDrop) {
                    const holdClip = actions.hangDrop.getClip().clone();
                    holdClip.name = 'hangHold';
                    actions.hangHold = mixer.clipAction(holdClip);
                    actions.hangHold.setLoop(THREE.LoopRepeat);
                    baseScale.hangHold = 0;
                }
    
                // jump clip speed so the whole clip fits the airtime
                if (actions.jump) {
                    const dur = actions.jump.getClip().duration;
                    const air = 2 * JUMP_V / GRAVITY;
                    const s = clamp(dur * (1 - JUMP_TAKEOFF_FRAC) / air, 0.6, 2.5);
                    baseScale.jump = s;
                    jumpDelay = JUMP_TAKEOFF_FRAC * dur / s;
                }
    
                mixer.addEventListener('finished', onClipFinished);
    
                // measure poses (character group is at identity right now)
                idlePose      = samplePose('idle', 0);
                hangEnd       = samplePose('hangDrop', 'end');
                hopEnd        = samplePose('hangHop', 'end');
                swingStart    = samplePose('swing', 0);
                swingLandEnd  = samplePose('swingLand', 'end');
                mixer.stopAllAction();
    
                if (actions.swing) buildSwingBar(swingStart.hand.y);
    
                const missing = names.filter(k => !actions[k]);
                if (missing.length) console.warn('Missing clips (features disabled):', missing);
    
                play('idle', { fade: 0 });
                ready = true;
                loadingEl.style.display = 'none';
            }
    
            /* =====================================================================
               ANIMATION CONTROL
            ===================================================================== */
            function play(name, { fade = 0.15, force = false } = {}) {
                const next = actions[name];
                if (!next) return null;
                if (next === activeAction && !force) return next;
                const prev = activeAction;
                next.reset();
                next.setEffectiveWeight(1);
                next.setEffectiveTimeScale(baseScale[name] ?? 1);
                if (prev && prev !== next && fade > 0) {
                    next.fadeIn(fade);
                    prev.fadeOut(fade);
                } else if (prev && prev !== next) {
                    prev.stop();
                }
                next.play();
                activeAction = next;
                activeName = name;
                return next;
            }
    
            function playHangHold() {
                const a = play('hangHold', { fade: 0.05, force: true });
                if (a) a.time = a.getClip().duration;
            }
    
            function onClipFinished(e) {
                const name = e.action.getClip().name;
                if (state === 'locked' && name === lockType) {
                    state = 'ground';
                    crawling = headroomBlocked();
                } else if (state === 'hang' && hangSub === 'enter' && name === 'hangDrop') {
                    hangSub = 'hold';
                    playHangHold();
                } else if (state === 'hang' && hangSub === 'hop' && name === 'hangHop') {
                    pos.x += pendingDelta.x;
                    pos.z += pendingDelta.z;
                    hangSub = 'hold';
                    playHangHold();
                } else if (state === 'swing' && swingSub === 'land' && name === 'swingLand') {
                    pos.x += pendingDelta.x;
                    pos.z += pendingDelta.z;
                    pos.y = 0;
                    state = 'ground';
                }
            }
    
            /* =====================================================================
               INPUT ACTIONS
            ===================================================================== */
            function onPress(code) {
                if (!ready) return;
                if (state === 'ground') {
                    switch (code) {
                        case 'Space': if (!crawling) startJump(); break;
                        case 'KeyV': startLocked('slide', LOW_H); break;
                        case 'KeyR': startLocked('roll', ROLL_H); break;
                        case 'KeyE': tryHang(); break;
                        case 'KeyF': trySwing(); break;
                    }
                } else if (state === 'hang' && hangSub === 'hold') {
                    if (code === 'KeyA' || code === 'KeyQ') hangHop();
                    else if (code === 'KeyS' || code === 'Space' || code === 'KeyX' || code === 'KeyE') hangRelease();
                } else if (state === 'swing' && swingSub === 'hold') {
                    if (code === 'KeyF' || code === 'Space' || code === 'KeyS') swingRelease();
                }
            }
    
            function startJump() {
                if (!actions.jump) return;
                state = 'air';
                airMode = 'jump';
                pendingJump = true;
                jumpTimer = jumpDelay;
                vy = 0;
                airSpeedCap = Math.max(SNEAK_SPEED, velH.length());
                play('jump', { force: true, fade: 0.1 });
            }
    
            function startLocked(type, h) {
                if (crawling || !actions[type]) return;
                state = 'locked';
                lockType = type;
                lockHeight = h;
                play(type, { force: true, fade: 0.1 });
            }
    
            function startFall() {
                state = 'air';
                airMode = 'fall';
                pendingJump = false;
                vy = 0;
                airSpeedCap = Math.max(SNEAK_SPEED, velH.length());
                if (actions.fall) {
                    play('fall', { force: true, fade: 0.1 });
                    actions.fall.time = actions.fall.getClip().duration * FALL_START_FRAC;
                }
            }
    
            function land(impact) {
                if (impact > HARD_LAND_V && actions.hardLand) {
                    state = 'locked';
                    lockType = 'hardLand';
                    lockHeight = STAND_H;
                    velH.set(0, 0, 0);
                    play('hardLand', { force: true, fade: 0.08 });
                } else {
                    state = 'ground';
                    crawling = false;
                }
            }
    
            /* ---- ledge hang ---- */
            function edgePoint(b, n) {
                if (n.x !== 0) return { x: n.x > 0 ? b.maxX : b.minX, z: clamp(pos.z, b.minZ + 0.5, b.maxZ - 0.5) };
                return { x: clamp(pos.x, b.minX + 0.5, b.maxX - 0.5), z: n.z > 0 ? b.maxZ : b.minZ };
            }
    
            function sideFree(b, n) {
                const p = edgePoint(b, n);
                const qx = p.x + n.x * 0.6, qz = p.z + n.z * 0.6;
                for (const o of boxes) {
                    if (o === b) continue;
                    if (qx >= o.minX && qx <= o.maxX && qz >= o.minZ && qz <= o.maxZ && o.top > b.top - 2.5 && o.bottom < b.top) return false;
                }
                return true;
            }
    
            function findHangEdge() {
                if (state !== 'ground' || crawling || !actions.hangDrop) return null;
                for (const b of boxes) {
                    if (!b.ledge) continue;
                    if (Math.abs(pos.y - b.top) > 0.1) continue;
                    if (pos.x < b.minX || pos.x > b.maxX || pos.z < b.minZ || pos.z > b.maxZ) continue;
                    const sides = [
                        [pos.x - b.minX, -1, 0], [b.maxX - pos.x, 1, 0],
                        [pos.z - b.minZ, 0, -1], [b.maxZ - pos.z, 0, 1]
                    ].sort((a, c) => a[0] - c[0]);
                    for (const s of sides) {
                        if (s[0] >= EDGE_GRAB) break;
                        const n = { x: s[1], z: s[2] };
                        if (sideFree(b, n)) return { box: b, n };
                    }
                }
                return null;
            }
    
            function tryHang() {
                const e = findHangEdge();
                if (!e) return;
                const { box, n } = e;
                const yaw = Math.atan2(-n.x, -n.z);       // face into the wall
                hangCtx = { box, n, yaw };
                const p = edgePoint(box, n);
                const off = l2w(hangEnd.hand.x, hangEnd.hand.z, yaw);
                pos.set(p.x - off.x, box.top - hangEnd.hand.y, p.z - off.z);
                facingAngle = targetAngle = yaw;
                velH.set(0, 0, 0);
                state = 'hang';
                hangSub = 'enter';
                play('hangDrop', { force: true, fade: 0.1 });
            }
    
            function hangHop() {
                if (!actions.hangHop || !hangCtx) return;
                const { box, n, yaw } = hangCtx;
                const d = l2w(hopEnd.hips.x - hangEnd.hips.x, hopEnd.hips.z - hangEnd.hips.z, yaw);
                const along = n.x !== 0 ? pos.z + d.z : pos.x + d.x;
                const lo = (n.x !== 0 ? box.minZ : box.minX) + 0.3;
                const hi = (n.x !== 0 ? box.maxZ : box.maxX) - 0.3;
                if (along < lo || along > hi) return;      // end of ledge
                pendingDelta = d;
                hangSub = 'hop';
                play('hangHop', { force: true, fade: 0.05 });
            }
    
            function hangRelease() {
                const n = hangCtx ? hangCtx.n : { x: 0, z: 0 };
                velH.set(n.x * 0.6, 0, n.z * 0.6);
                startFall();
            }
    
            /* ---- swing bar ---- */
            function nearBar() {
                return !!bar && !!actions.swing && state === 'ground' && !crawling && Math.abs(pos.y) < 0.05 &&
                    Math.abs(pos.x - bar.x) <= bar.half && Math.abs(pos.z - bar.z) < BAR_REACH;
            }
    
            function trySwing() {
                if (!nearBar()) return;
                const dirZ = (bar.z - pos.z) >= 0 ? 1 : -1;
                const yaw = Math.atan2(0, dirZ);
                const off = l2w(swingStart.hand.x, swingStart.hand.z, yaw);
                pos.set(clamp(pos.x, bar.x - bar.half + 0.3, bar.x + bar.half - 0.3) - off.x, 0, bar.z - off.z);
                facingAngle = targetAngle = yaw;
                hangCtx = null;
                velH.set(0, 0, 0);
                state = 'swing';
                swingSub = 'hold';
                play('swing', { force: true, fade: 0.1 });
            }
    
            function swingRelease() {
                if (!actions.swingLand) return;
                pendingDelta = l2w(swingLandEnd.hips.x - idlePose.hips.x, swingLandEnd.hips.z - idlePose.hips.z, facingAngle);
                swingSub = 'land';
                play('swingLand', { force: true, fade: 0.1 });
            }
    
            /* =====================================================================
               UPDATE
            ===================================================================== */
            /* =====================================================================
               EDIT MODE: select / resize / move / clone / delete objects
            ===================================================================== */
            function $(id) { return document.getElementById(id); }
    
            function saveLayoutNow() {
                try {
                    localStorage.setItem(LAYOUT_KEY, JSON.stringify(
                        boxes.map(b => [b.minX, b.maxX, b.minZ, b.maxZ, b.bottom, b.top, b.color, b.ledge ? 1 : 0])
                    ));
                } catch (e) { /* storage unavailable */ }
            }
    
            function saveLayoutSoon() {
                clearTimeout(saveTimer);
                saveTimer = setTimeout(saveLayoutNow, 300);
            }
    
            function loadLayoutOrDefault() {
                let data = null;
                try { data = JSON.parse(localStorage.getItem(LAYOUT_KEY)); } catch (e) { data = null; }
                if (Array.isArray(data) && data.length) {
                    for (const r of data) addBox(r[0], r[1], r[2], r[3], r[4], r[5], r[6], { ledge: !!r[7] });
                } else {
                    buildLevel();
                }
            }
    
            function rebuildBoxVisual(b) {
                const w = b.maxX - b.minX, h = b.top - b.bottom, d = b.maxZ - b.minZ;
                b.mesh.geometry.dispose();
                b.mesh.geometry = new THREE.BoxGeometry(w, h, d);
                b.edges.geometry.dispose();
                b.edges.geometry = new THREE.EdgesGeometry(b.mesh.geometry);
                const cx = (b.minX + b.maxX) / 2, cy = b.bottom + h / 2, cz = (b.minZ + b.maxZ) / 2;
                b.mesh.position.set(cx, cy, cz);
                b.edges.position.set(cx, cy, cz);
                const sel = (b === selected);
                b.edges.material.color.setHex(sel ? 0xffeb3b : (b.ledge ? 0xffb74d : 0x000000));
                b.edges.material.opacity = sel ? 1 : 0.55;
            }
    
            function setRay(e) {
                const r = renderer.domElement.getBoundingClientRect();
                ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
                raycaster.setFromCamera(ndc, camera);
            }
    
            function pickBox(e) {
                setRay(e);
                const hits = raycaster.intersectObjects(boxes.map(b => b.mesh), false);
                return hits.length ? { box: hits[0].object.userData.box, point: hits[0].point } : null;
            }
    
            function selectBox(b) {
                const prev = selected;
                selected = b;
                if (prev) { prev.mesh.material.emissive.setHex(0x000000); rebuildBoxVisual(prev); }
                if (b) { b.mesh.material.emissive.setHex(0x444400); rebuildBoxVisual(b); }
                refreshPanel();
            }
    
            function refreshPanel() {
                const b = selected;
                for (const id of ['f-x', 'f-z', 'f-y', 'f-w', 'f-h', 'f-d', 'f-ledge', 'btnSmaller', 'btnBigger', 'btnClone', 'btnDelete']) {
                    $(id).disabled = !b;
                }
                $('editTitle').textContent = b ? 'Selected object' : 'Click an object to select';
                if (!b) return;
                const r = v => +v.toFixed(2);
                $('f-x').value = r((b.minX + b.maxX) / 2);
                $('f-z').value = r((b.minZ + b.maxZ) / 2);
                $('f-y').value = r(b.bottom);
                $('f-w').value = r(b.maxX - b.minX);
                $('f-h').value = r(b.top - b.bottom);
                $('f-d').value = r(b.maxZ - b.minZ);
                $('f-ledge').checked = b.ledge;
            }
    
            function applyPanel() {
                const b = selected;
                if (!b) return;
                const v = id => parseFloat($(id).value);
                const cx = v('f-x'), cz = v('f-z'), bot = v('f-y');
                const w = v('f-w'), h = v('f-h'), d = v('f-d');
                if ([cx, cz, bot, w, h, d].some(n => isNaN(n))) return;
                const W = Math.max(0.2, w), H = Math.max(0.1, h), D = Math.max(0.2, d);
                b.minX = cx - W / 2; b.maxX = cx + W / 2;
                b.minZ = cz - D / 2; b.maxZ = cz + D / 2;
                b.bottom = Math.max(0, bot); b.top = b.bottom + H;
                rebuildBoxVisual(b);
                saveLayoutSoon();
            }
    
            function moveSelectedTo(cx, cz) {
                const b = selected;
                if (!b) return;
                const w = b.maxX - b.minX, d = b.maxZ - b.minZ;
                b.minX = cx - w / 2; b.maxX = cx + w / 2;
                b.minZ = cz - d / 2; b.maxZ = cz + d / 2;
                rebuildBoxVisual(b);
                refreshPanel();
            }
    
            function scaleSelected(k) {
                const b = selected;
                if (!b) return;
                const w = (b.maxX - b.minX) * k, d = (b.maxZ - b.minZ) * k, h = (b.top - b.bottom) * k;
                if (w < 0.2 || d < 0.2 || h < 0.1) return;
                const cx = (b.minX + b.maxX) / 2, cz = (b.minZ + b.maxZ) / 2;
                b.minX = cx - w / 2; b.maxX = cx + w / 2;
                b.minZ = cz - d / 2; b.maxZ = cz + d / 2;
                b.top = b.bottom + h;
                rebuildBoxVisual(b);
                refreshPanel();
                saveLayoutSoon();
            }
    
            function cloneSelected() {
                const s = selected;
                if (!s) return;
                const w = s.maxX - s.minX;
                const nb = addBox(s.minX + w + 0.5, s.maxX + w + 0.5, s.minZ, s.maxZ, s.bottom, s.top, s.color, { ledge: s.ledge });
                selectBox(nb);
                saveLayoutSoon();
            }
    
            function addNewBox() {
                const cx = Math.round((pos.x + Math.sin(facingAngle) * 3) * 2) / 2;
                const cz = Math.round((pos.z + Math.cos(facingAngle) * 3) * 2) / 2;
                const nb = addBox(cx - 1, cx + 1, cz - 1, cz + 1, 0, 1, 0x6a7a8a);
                selectBox(nb);
                saveLayoutSoon();
            }
    
            function deleteSelected() {
                const b = selected;
                if (!b) return;
                selectBox(null);
                scene.remove(b.mesh); scene.remove(b.edges);
                b.mesh.geometry.dispose(); b.mesh.material.dispose();
                b.edges.geometry.dispose(); b.edges.material.dispose();
                boxes.splice(boxes.indexOf(b), 1);
                saveLayoutSoon();
            }
    
            function setEditMode(on) {
                editMode = on;
                $('editPanel').style.display = on ? 'block' : 'none';
                $('editBtn').innerHTML = on ? '✔ Done' : '✎ Edit';
                renderer.domElement.style.cursor = on ? 'crosshair' : 'default';
                if (!on) selectBox(null);
            }
    
            function setupEditUI() {
                const click = (id, fn) => $(id).addEventListener('click', () => { fn(); $(id).blur(); });
                click('editBtn', () => setEditMode(!editMode));
                click('btnClone', cloneSelected);
                click('btnDelete', deleteSelected);
                click('btnNew', addNewBox);
                click('btnBigger', () => scaleSelected(1.1));
                click('btnSmaller', () => scaleSelected(1 / 1.1));
                click('btnReset', () => {
                    if (!confirm('Reset the whole course to the default layout?')) return;
                    try { localStorage.removeItem(LAYOUT_KEY); } catch (e) { /* ignore */ }
                    location.reload();
                });
                for (const id of ['f-x', 'f-z', 'f-y', 'f-w', 'f-h', 'f-d']) $(id).addEventListener('input', applyPanel);
                $('f-ledge').addEventListener('change', () => {
                    if (!selected) return;
                    selected.ledge = $('f-ledge').checked;
                    rebuildBoxVisual(selected);
                    saveLayoutSoon();
                    $('f-ledge').blur();
                });
                refreshPanel();
            }
    
            function getInput() {
                const z = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0);
                const x = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
                _fwd.set(-Math.sin(camYaw), 0, -Math.cos(camYaw));
                _right.set(Math.cos(camYaw), 0, -Math.sin(camYaw));
                _move.set(0, 0, 0).addScaledVector(_fwd, z).addScaledVector(_right, x);
                const has = _move.lengthSq() > 0;
                if (has) _move.normalize();
                return { has, dir: _move };
            }
    
            function updateGround(dt, inp) {
                crawling = !!keys.KeyC || (crawling && headroomBlocked());
    
                let speed = 0, anim = 'idle';
                if (crawling) {
                    anim = 'crawl';
                    speed = inp.has ? CRAWL_SPEED : 0;
                } else if (inp.has) {
                    const sprint = keys.ShiftLeft || keys.ShiftRight;
                    anim = sprint ? 'sprint' : 'sneak';
                    speed = sprint ? SPRINT_SPEED : SNEAK_SPEED;
                }
                if (!actions[anim]) anim = actions.sneak ? 'sneak' : 'idle';
    
                const k = 1 - Math.exp(-14 * dt);
                velH.x += ((inp.has ? inp.dir.x * speed : 0) - velH.x) * k;
                velH.z += ((inp.has ? inp.dir.z * speed : 0) - velH.z) * k;
                if (!inp.has && velH.lengthSq() < 0.01) velH.set(0, 0, 0);
                if (inp.has) targetAngle = Math.atan2(inp.dir.x, inp.dir.z);
    
                pos.x += velH.x * dt;
                pos.z += velH.z * dt;
                resolveCollisions(crawling ? LOW_H : STAND_H);
    
                play(anim);
                if (anim === 'crawl' && actions.crawl) {
                    actions.crawl.setEffectiveTimeScale(inp.has ? baseScale.crawl : 0);
                }
    
                const g = getGroundHeight(pos.x, pos.z, pos.y);
                if (g < pos.y - SNAP_DOWN) startFall();
                else pos.y = g;
            }
    
            function updateAir(dt, inp) {
                // horizontal control
                if (inp.has) {
                    const k = 1 - Math.exp(-3 * dt);
                    velH.x += (inp.dir.x * airSpeedCap - velH.x) * k;
                    velH.z += (inp.dir.z * airSpeedCap - velH.z) * k;
                    targetAngle = Math.atan2(inp.dir.x, inp.dir.z);
                } else {
                    const k = Math.exp(-0.3 * dt);
                    velH.x *= k; velH.z *= k;
                }
    
                let landed = false, impact = 0;
    
                if (pendingJump) {
                    jumpTimer -= dt;
                    if (jumpTimer <= 0) { vy = JUMP_V; pendingJump = false; }
                } else {
                    vy -= GRAVITY * dt;
                    const g = getGroundHeight(pos.x, pos.z, pos.y);
                    const ny = pos.y + vy * dt;
                    if (vy <= 0 && ny <= g) {
                        pos.y = g;
                        landed = true;
                        impact = -vy;
                    } else {
                        pos.y = ny;
                    }
                }
    
                pos.x += velH.x * dt;
                pos.z += velH.z * dt;
                resolveCollisions(STAND_H);
    
                // freeze the falling pose
                if (airMode === 'fall' && actions.fall && activeAction === actions.fall) {
                    const dur = actions.fall.getClip().duration;
                    if (actions.fall.time >= dur * FALL_HOLD_FRAC) actions.fall.setEffectiveTimeScale(0);
                }
    
                if (landed) land(impact);
            }
    
            function updateLocked(dt) {
                if (lockType === 'slide' || lockType === 'roll') {
                    const dur = activeAction ? activeAction.getClip().duration : 1;
                    const prog = activeAction ? clamp(activeAction.time / dur, 0, 1) : 0;
                    const speed = lockType === 'slide'
                        ? SLIDE_SPEED * (1 - 0.85 * prog)
                        : ROLL_SPEED * (1 - 0.3 * prog);
                    const dx = Math.sin(facingAngle), dz = Math.cos(facingAngle);
                    velH.set(dx * speed, 0, dz * speed);
                    pos.x += velH.x * dt;
                    pos.z += velH.z * dt;
                    resolveCollisions(lockHeight);
    
                    const g = getGroundHeight(pos.x, pos.z, pos.y);
                    if (g < pos.y - SNAP_DOWN) startFall();
                    else pos.y = g;
                } else {
                    velH.set(0, 0, 0);
                }
            }
    
            function update(dt) {
                const inp = getInput();
                switch (state) {
                    case 'ground': updateGround(dt, inp); break;
                    case 'air':    updateAir(dt, inp); break;
                    case 'locked': updateLocked(dt); break;
                    default: break;   // hang / swing: pinned to the ledge / bar
                }
    
                if (state === 'ground' || state === 'air') {
                    facingAngle = shortestAngleLerp(facingAngle, targetAngle, TURN_SPEED * dt);
                }
                characterGroup.rotation.y = facingAngle + MODEL_YAW_OFFSET;
            }
    
            function updatePrompt() {
                let msg = '';
                if (state === 'ground') {
                    if (findHangEdge()) msg = '[E] Lower into ledge hang';
                    else if (nearBar()) msg = '[F] Grab bar';
                } else if (state === 'hang' && hangSub === 'hold') {
                    msg = '[A] Hop left   [S / Space] Drop';
                } else if (state === 'swing' && swingSub === 'hold') {
                    msg = '[F / Space] Release';
                }
                if (msg !== lastPrompt) {
                    lastPrompt = msg;
                    promptEl.textContent = msg;
                    promptEl.style.display = msg ? 'block' : 'none';
                }
                const st = state + (state === 'locked' ? ': ' + lockType : '') + (state === 'hang' ? ': ' + hangSub : '') + (state === 'swing' ? ': ' + swingSub : '');
                if (st !== lastStatus) { lastStatus = st; statusEl.textContent = st; }
            }
    
            function updateCamera(dt) {
                const desired = new THREE.Vector3(pos.x, pos.y + 1.1, pos.z);
                camTarget.lerp(desired, 1 - Math.exp(-10 * dt));
    
                const cp = Math.cos(camPitch);
                camera.position.set(
                    camTarget.x + Math.sin(camYaw) * cp * camDist,
                    camTarget.y + Math.sin(camPitch) * camDist,
                    camTarget.z + Math.cos(camYaw) * cp * camDist
                );
                camera.lookAt(camTarget);
    
                dirLight.position.set(pos.x + 5, pos.y + 12, pos.z + 5);
                dirLight.target.position.copy(pos);
            }
    
            function animate() {
                requestAnimationFrame(animate);
                const dt = Math.min(clock.getDelta(), 0.05);
                if (ready) {
                    mixer.update(dt);
                    update(dt);
                    updatePrompt();
                }
                updateCamera(dt);
                renderer.render(scene, camera);
            }
        </script>
    </body>
    </html>
    
    Expand

    this is just base on https://mero.live/build/battle old stuff. free time!!

  • Breakout – Update 🐵 🍉

    🐵 Monkey

    • Each level has 1 or 2 🐵 bricks at random.
    • When a ball hits one, the 🐵 does zoom, zoom, zoom, zoom: four pulses, each bigger than the last, each with a ring and a rising beep. It then bursts.
    • After the burst, every ball moves at 2x speed for 20 seconds. Balls that appear during that time (from 🍇 or a respawn) are also 2x.
    • Balls get an orange trail while they are fast. A small 🐵 20s countdown shows at the top left.

    🍉 Watermelon

    • Each level has 1 or 2 🍉 bricks at random.
    • Hitting one bursts it and drops 10 red seed dots in random directions. They arc and fall with gravity.
    • Each dot the paddle catches shrinks it by 2px. Catching all 10 gives -20px, and missed dots do nothing.
    • The shrink lasts 10 seconds. Each catch refreshes the timer. After that the paddle smoothly grows back to its original size.
    • Two melons can stack up to -40px, and the paddle never goes below 60px wide. The HUD shows 🍉 8s (-12px).

    Notes

    • I read “each game” as each level. To make it once per whole game, tell me and I’ll change it.
    • I removed 🐵 and 🍉 from the random filler emojis, so the counts stay exact.
    • Both effects reset on level change, game over and restart.
    • To tune it, change MONKEY_TIME, MELON_TIME, DOT_SHRINK and MIN_PADDLE at the top.
    • Bricks broken by a 🌈 splash don’t trigger 🐵 or 🍉, the same as the other special bricks.
  • Breakout – Update

    Breakout – Update

    Building Breakout: A Tiny Offline Breakout Game With Emoji Chaos

    Breakout started as a plain Breakout clone: a paddle, a ball, and rows of bricks. Over a few sessions it grew into a small offline-ready PWA with power-ups, animations, generated sound, and a few surprises. Here is what we built and how it works.

    The Game

    • Shaped levels. Nine hand-made brick patterns (pyramid, heart, 0, 8, 6, A, W, M) drawn from tiny string maps. After level 9, symmetric random layouts take over.
    • Emoji bricks. Every brick carries an emoji, and a few of them are special.
    • Fully offline. It runs as an installable PWA and works in airplane mode.
    • No audio files. All sound is generated live with the Web Audio API.

    The Special Bricks

    EmojiWhat it does
    ⚽️Piercing ball for 10 seconds. It smashes through bricks without bouncing.
    🌈Rainbow splash. It destroys every brick in a small radius.
    🍎A falling apple. Catch it and it grows, goes BOOM, and turns the ball big for 8 seconds while giving it a speed burst.
    🍇A falling grape. Catch it and it grows, goes BOOM, and fires two fast bullet-balls upward in a fan.
    💎Bubble shield. Every brick gets a glowing shield for 30 seconds and needs two hits.
    🦖A dino stomps across the screen while the whole screen shakes. Big levels get two dinos.
    ❤️Extra life. It appears on every 5th level, or when a bomb leaves you at 1 life.
    💣Two hidden bomb carriers per level. Only one bomb falls at a time, and catching it on the paddle costs a life (never the last one).

    The Polish

    • Grow, then boom. Caught fruit swells up with a rising charge sound, then explodes into particles and a shockwave ring.
    • Bullet speed. Shot balls launch at about twice the speed, with a yellow trail, and ease back to normal over about a second and a half.
    • Heart drama. When a bomb costs you a life, the ❤️ in the HUD shakes, cracks into 💔, sheds pieces, and heals back.
    • Smooth big ball. The ball scales up and down instead of snapping.

    Bugs We Hunted

    • The freeze. One error inside the animation frame killed the whole loop. The fix was a try/catch around each frame, a check for removed bricks during a rainbow splash, and a fallback for browsers without roundRect.
    • Splice while iterating. Removing items from an array during forEach skips the next one. All loops now run backwards.
    • Tunneling. Fast balls could skip through thin bricks, so each frame is split into sub-steps of 8px or less.
    • Stacking timers. Power-up timers now count down in game time, so they can’t stack, can’t fire late, and pause with the game.
    • Phone shake lag. Moving the whole HTML container every frame was heavy on phones. The shake now happens inside the canvas.

    Tiny Code Tour

    Levels are strings. "01100110" is one row, and 1 means a brick:

    js

    ["01100110","11111111","11111111","01111110","00111100"] // heart

    Sound without files. One oscillator and a fading gain gives a beep:

    js

    osc.frequency.setValueAtTime(freq, t);
    gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);

    Speed burst that fades. Each ball has a boost multiplier that decays every frame:

    js

    this.boost = this.boost > 1.01 ? 1 + (this.boost - 1) * 0.965 : 1;

    Tunneling fix. Move in small steps and check bricks after each one:

    js

    const n = Math.max(1, Math.ceil(ball.v / 8));
    for (let s = 0; s < n; s++) { ball.advance(n); hitBricks(ball); }

    Offline in a few lines. The service worker caches the page and images. It uses network-first for the page and cache-first for assets, and the cache name includes the file’s modified time, so uploading a new index.php refreshes everything automatically:

    php

    $VERSION = filemtime(__FILE__);
    echo "const CACHE = 'breakout-v{$VERSION}';";

    One-bomb rule. New bombs wait in a queue until the last one is gone:

    js

    if (fruits.some(f => f.type === 'bomb')) bombQueue.push({x, y});
    else fruits.push(new PowerUp(x, y, '💣', 'bomb'));

    Stack

    One index.php file, plain Canvas 2D, Web Audio, a service worker and a manifest, plus four images (ball, paddle, block, fav). There are no frameworks and no build step.

    What’s Next

    • A separate maskable icon and 192px icon for Android
    • High score saving
    • Pause button and a smarter touch offset so a finger doesn’t cover the paddle
    • More brick types with different hit points

    Building it was mostly a loop of “add one silly idea, break something, fix it.” Each fix made the game a little tighter, and each silly idea made it a little more fun.

  • high-quality WebP + 512px optimization, and save them as _clean.glb.

    Here is the fresh script. It will automatically loop through all your original 3D files, apply the high-quality WebP + 512px optimization, and save them as _clean.glb.

    Copy and paste this into your terminal:

    bash

    for f in *.glb; do
       # Skip clean files if rerun
       [[ "$f" == *"_clean.glb" ]] && continue
       
       base="${f%.glb}"
       echo "Optimizing $f to high-quality _clean.glb..."
       
       # 1. Resize to 512x512 (keeps details crisp)
       gltf-transform resize "$f" "${base}_clean.glb" --width 512 --height 512 && \
       
       # 2. Prune duplicate/empty data
       gltf-transform prune "${base}_clean.glb" "${base}_clean.glb" && \
       
       # 3. Compress textures using WebP (small size, high quality)
       gltf-transform webp "${base}_clean.glb" "${base}_clean.glb" --slots baseColor && \
       
       # 4. Smooth Draco geometry compression (prevents distortion)
       gltf-transform draco "${base}_clean.glb" "${base}_clean.glb" --quantizePosition 11 --quantizeTexcoord 12
    done
    

    Use code with caution.

  • Protected: mero.live order

    This content is password-protected. To view it, please enter the password below.

  • ride

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Ride</title>
        <style>
            body { margin: 0; overflow: hidden; background-color: #fff; }
            canvas { display: block; }
        </style>
    </head>
    <body>
    
        <script type="importmap">
            {
                "imports": {
                    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
                }
            }
        </script>
    
        <script type="module">
            import * as THREE from 'three';
            import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
            import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
            import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
    
            // --- CONFIGURATION ---
            const MODELS_FOLDER = './glb/'; 
            const MODEL_FILES = ['dirty.glb', 'truck.glb']; 
            const TARGET_WIDTH = 3; 
            const WORLD_SPEED = 0.6; 
            const LANE_WIDTH = 8;    
    
            // --- SCENE SETUP ---
            const scene = new THREE.Scene();
            scene.background = new THREE.Color(0xffffff); 
    
            const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
            camera.position.set(15, 10, 25);
    
            const renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(window.devicePixelRatio);
            document.body.appendChild(renderer.domElement);
    
            // STUDIO LIGHTING
            scene.add(new THREE.AmbientLight(0xffffff, 1.0));
            const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1.5);
            hemiLight.position.set(0, 20, 0);
            scene.add(hemiLight);
    
            const dirLight = new THREE.DirectionalLight(0xffffff, 2);
            dirLight.position.set(10, 20, 10);
            scene.add(dirLight);
    
            const controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
    
            const clock = new THREE.Clock();
    
            // --- THE INFINITE ROAD ---
            const roadGeo = new THREE.PlaneGeometry(40, 1000);
            const roadMat = new THREE.MeshStandardMaterial({ color: 0xdddddd }); 
            const road = new THREE.Mesh(roadGeo, roadMat);
            road.rotation.x = -Math.PI / 2;
            scene.add(road);
    
            // FLAT ROAD MARKERS (No Height)
            const markers = [];
            for(let i = 0; i < 40; i++) {
                // Use PlaneGeometry for zero height (like paint)
                const markerGeo = new THREE.PlaneGeometry(0.4, 2); 
                const markerMat = new THREE.MeshStandardMaterial({ 
                    color: 0xbbbbbb,
                    side: THREE.DoubleSide 
                });
                const marker = new THREE.Mesh(markerGeo, markerMat);
                
                // Rotate to lay flat on road
                marker.rotation.x = -Math.PI / 2;
                
                // Position slightly above 0 (0.01) to prevent flickering (Z-fighting)
                marker.position.set(0, 0.01, (i * 10) - 200);
                
                scene.add(marker);
                markers.push(marker);
            }
    
            // LOADER SETUP
            const loader = new GLTFLoader();
            const dracoLoader = new DRACOLoader();
            dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');
            loader.setDRACOLoader(dracoLoader);
    
            const allActiveCars = []; 
            const mixers = [];
    
            function normalizeModelSize(model) {
                const box = new THREE.Box3().setFromObject(model);
                const size = new THREE.Vector3();
                box.getSize(size);
                const scaleFactor = TARGET_WIDTH / size.x;
                model.scale.set(scaleFactor, scaleFactor, scaleFactor);
                const center = new THREE.Vector3();
                box.getCenter(center);
                model.position.x -= center.x * scaleFactor;
                model.position.y -= center.y * scaleFactor;
                model.position.z -= center.z * scaleFactor;
            }
    
            async function loadAndStream() {
                try {
                    for (let i = 0; i < MODEL_FILES.length; i++) {
                        const gltf = await loader.loadAsync(MODELS_FOLDER + MODEL_FILES[i]);
                        const model = gltf.scene;
                        normalizeModelSize(model);
                        const xPos = (i * LANE_WIDTH) - ((MODEL_FILES.length - 1) * LANE_WIDTH / 2);
                        model.position.set(xPos, 0, 0);
                        model.rotation.y = 0; 
                        scene.add(model);
                        allActiveCars.push(model);
    
                        if (gltf.animations?.length > 0) {
                            const mixer = new THREE.AnimationMixer(model);
                            mixer.clipAction(gltf.animations[0]).play();
                            mixers.push(mixer);
                        }
                    }
                } catch (err) { console.error("Load Error:", err); }
            }
    
            function animate() {
                requestAnimationFrame(animate);
                const delta = clock.getDelta();
                mixers.forEach(m => m.update(delta));
                
                // MOVE ROAD MARKERS (Creates the illusion of driving)
                markers.forEach(marker => {
                    marker.position.z -= WORLD_SPEED;
                    if (marker.position.z < -100) {
                        marker.position.z = 100; 
                    }
                });
    
                controls.update();
                renderer.render(scene, camera);
            }
    
            window.addEventListener('resize', () => {
                camera.aspect = window.innerWidth / window.innerHeight;
                camera.updateProjectionMatrix();
                renderer.setSize(window.innerWidth, window.innerHeight);
            });
    
            loadAndStream();
            animate();
        </script>
    </body>
    </html>
    Expand

    https://mero.live/build/ride

    glb – https://sketchfab.com/search?features=downloadable+animated&q=car&type=models

  • roll

    <!DOCTYPE html>
    <html>
    <head>
        <title>Roll - Dark Descent</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
        <style>
            body { margin: 0; overflow: hidden; font-family: Arial, sans-serif; touch-action: none; background-color: #fffafa; }
            #TutContainer { width: 100%; height: 100vh; }
            #scoreBoard { position: absolute; top: 20px; left: 20px; font-size: 20px; font-weight: bold; color: #333; z-index: 10; pointer-events: none; }
            #gameOverScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: none; flex-direction: column; justify-content: center; align-items: center; z-index: 100; color: white; }
            #gameOverScreen h1 { font-size: 32px; margin-bottom: 20px; text-align: center; }
            #restartBtn { padding: 15px 30px; font-size: 20px; cursor: pointer; background: #33ff33; border: none; border-radius: 5px; font-weight: bold; }
        </style>
    </head>
    <body>
        <div id="scoreBoard">Score: 0</div> 
        <div id="gameOverScreen">
            <h1>GAME OVER</h1>
            <button id="restartBtn">RUN AGAIN</button>
        </div>
        <div id="TutContainer"></div>  
        <script src="https://cdn.jsdelivr.net/npm/three@0.145.0/build/three.min.js"></script>   
         <script>
           let sceneWidth, sceneHeight, camera, scene, renderer, dom;
            let sun, heroRollingSpeed, clock, treesPool, treesInPath;
            let pathAngleValues;
            let globalRenderID;
    
            const worldRadius = 26;
            const heroRadius = 0.2;
            const heroBaseY = 2.1; 
            const gravity = 0.005;
            const particleCount = 20;
    
            let rollingSpeed = 0.008; 
            let treeReleaseInterval = 0.5; 
            let rightLane = 1;
            let middleLane = 0;
            let leftLane = -1;
            let lerpSpeed = 0.15; 
    
            let scoreText, score, hasCollided;
            let jumping = false;
            let bounceValue = 0;
            let currentLane = middleLane;
            let heroSphere;
            let rollingGroundSphere;
            let particles;
            let particleGeometry;
            let explosionPower = 1;
            let sphericalHelper;
    
            let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
            const minSwipeDistance = 30;
    
            init();
    
            function init() {
                adjustSettings(); 
                createScene();
                update();
            }
    
            function adjustSettings() {
                if (window.innerWidth < 768) {
                    rollingSpeed = 0.005;
                    treeReleaseInterval = 1.0;
                    leftLane = -0.6;
                    rightLane = 0.6;
                    lerpSpeed = 0.25;
                } else {
                    rollingSpeed = 0.008;
                    treeReleaseInterval = 0.5;
                    leftLane = -1;
                    rightLane = 1;
                    lerpSpeed = 0.15;
                }
            }
    
            function createScene() {
                hasCollided = false;
                score = 0;
                treesPool = [];
                treesInPath = [];
                clock = new THREE.Clock();
                clock.start();
                
                heroRollingSpeed = (rollingSpeed * worldRadius / heroRadius) / 5;
                sphericalHelper = new THREE.Spherical();
                pathAngleValues = [1.52, 1.57, 1.62];
    
                sceneWidth = window.innerWidth;
                sceneHeight = window.innerHeight;
    
                scene = new THREE.Scene();
                scene.background = new THREE.Color(0xfffafa);
                scene.fog = new THREE.FogExp2(0xf0fff0, 0.14);
    
                camera = new THREE.PerspectiveCamera(60, sceneWidth / sceneHeight, 0.1, 1000);
                renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
                renderer.setClearColor(0xfffafa, 1);
                renderer.shadowMap.enabled = true;
                renderer.shadowMap.type = THREE.PCFSoftShadowMap;
                renderer.setSize(sceneWidth, sceneHeight);
    
                dom = document.getElementById('TutContainer');
                dom.appendChild(renderer.domElement);
                scoreText = document.getElementById('scoreBoard');
    
                createTreesPool();
                addWorld();
                addLight();
                addHero();
                addExplosion();
    
                camera.position.z = 6.5;
                camera.position.y = 2.5;
    
                window.addEventListener('resize', onWindowResize, false);
                document.addEventListener('keydown', handleKeyDown);
                document.addEventListener('touchstart', handleTouchStart, {passive: false});
                document.addEventListener('touchend', handleTouchEnd, {passive: false});
    
                const restartBtn = document.getElementById('restartBtn');
                if (restartBtn) {
                    restartBtn.addEventListener('click', () => { location.reload(); });
                }
            }
    
            function moveLeft() {
                if (hasCollided) return;
                if (currentLane === middleLane) currentLane = leftLane;
                else if (currentLane === rightLane) currentLane = middleLane;
                triggerJumpEffect();
            }
    
            function moveRight() {
                if (hasCollided) return;
                if (currentLane === middleLane) currentLane = rightLane;
                else if (currentLane === leftLane) currentLane = middleLane;
                triggerJumpEffect();
            }
    
            function jump() {
                if (hasCollided) return;
                if (!jumping) {
                    bounceValue = 0.12;
                    jumping = true;
                }
            }
    
            function triggerJumpEffect() {
                if (!jumping) {
                    jumping = true;
                    bounceValue = 0.06;
                }
            }
    
            function handleKeyDown(event) {
                const key = event.key.toLowerCase();
                if (key === 'a') moveLeft();
                else if (key === 'd') moveRight();
                else if (key === ' ') jump();
            }
    
            function handleTouchStart(e) {
                touchStartX = e.changedTouches[0].screenX;
                touchStartY = e.changedTouches[0].screenY;
            }
    
            function handleTouchEnd(e) {
                touchEndX = e.changedTouches[0].screenX;
                touchEndY = e.changedTouches[0].screenY;
                let dx = touchEndX - touchStartX;
                let dy = touchEndY - touchStartY;
                if (Math.abs(dx) > Math.abs(dy)) {
                    if (Math.abs(dx) > minSwipeDistance) {
                        if (dx < 0) moveLeft(); else moveRight();
                    }
                } else {
                    if (Math.abs(dy) > minSwipeDistance) {
                        if (dy < 0) jump();
                    }
                }
            }
    
            function update() {
                if (hasCollided) {
                    doExplosionLogic();
                    render();
                    globalRenderID = requestAnimationFrame(update);
                    return; 
                }
    
                // 1. Update Lighting and Atmosphere based on score
                updateAtmosphere();
    
                rollingGroundSphere.rotation.x += rollingSpeed;
                heroSphere.rotation.x -= heroRollingSpeed;
    
                if (heroSphere.position.y <= heroBaseY) {
                    jumping = false;
                    bounceValue = (Math.random() * 0.04) + 0.005;
                }
                heroSphere.position.y += bounceValue;
                heroSphere.position.x = THREE.MathUtils.lerp(heroSphere.position.x, currentLane, lerpSpeed); 
                bounceValue -= gravity;
    
                if (clock.getElapsedTime() > treeReleaseInterval) {
                    clock.start();
                    addPathTree();
                    score += 1;
                    if (scoreText) scoreText.innerHTML = "Score: " + score;
                }
    
                doTreeLogic();
                doExplosionLogic();
                render();
                globalRenderID = requestAnimationFrame(update);
            }
    
            function updateAtmosphere() {
                // Gradually get darker. Max darkness at score 200.
                let darkness = Math.min(score / 200, 1); 
                
                // Light intensity fades from 0.9 to 0.2
                sun.intensity = THREE.MathUtils.lerp(0.9, 0.2, darkness);
                
                // Background color fades from White to Deep Midnight Blue
                scene.background.setRGB(
                    THREE.MathUtils.lerp(1, 0.05, darkness), 
                    THREE.MathUtils.lerp(1, 0.05, darkness), 
                    THREE.MathUtils.lerp(1, 0.2, darkness)
                );
                
                // Fog changes color to match
                scene.fog.color.setRGB(
                    THREE.MathUtils.lerp(0.94, 0.05, darkness), 
                    THREE.MathUtils.lerp(1, 0.05, darkness), 
                    THREE.MathUtils.lerp(0.94, 0.2, darkness)
                );
            }
    
            function render() {
                renderer.render(scene, camera);
            }
    
            function gameOver() {
                const screen = document.getElementById('gameOverScreen');
                if (screen) screen.style.display = 'flex';
            }
    
            function createTreesPool() {
                var maxTreesInPool = 15;
                for (var i = 0; i < maxTreesInPool; i++) {
                    treesPool.push(createTree());
                }
            }
    
            function addPathTree() {
                var options = [0, 1, 2];
                var lane = Math.floor(Math.random() * 3);
                addTree(true, lane);
                options.splice(lane, 1);
                if (Math.random() > 0.5) {
                    lane = Math.floor(Math.random() * 2);
                    addTree(true, options[lane]);
                }
            }
    
            function addHero() {
                var sphereGeometry = new THREE.DodecahedronGeometry(heroRadius, 1);
                var sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xe5f2f2, flatShading: true });
                heroSphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
                heroSphere.receiveShadow = true;
                heroSphere.castShadow = true;
                scene.add(heroSphere);
                heroSphere.position.set(middleLane, heroBaseY, 4.8);
                currentLane = middleLane;
            }
    
            function addWorld() {
                var sides = 40, tiers = 40;
                var sphereGeometry = new THREE.SphereGeometry(worldRadius, sides, tiers);
                var sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xfffafa, flatShading: true });
                
                const pos = sphereGeometry.attributes.position;
                for (let i = 0; i < pos.count; i++) {
                    let x = pos.getX(i);
                    let y = pos.getY(i);
                    let z = pos.getZ(i);
                    let vec = new THREE.Vector3(x, y, z).normalize();
                    let heightValue = (Math.random() * 0.05) - 0.025;
                    vec.multiplyScalar(worldRadius + heightValue);
                    pos.setXYZ(i, vec.x, vec.y, vec.z);
                }
                pos.needsUpdate = true;
    
                rollingGroundSphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
                rollingGroundSphere.receiveShadow = true;
                rollingGroundSphere.rotation.z = -Math.PI / 2;
                scene.add(rollingGroundSphere);
                rollingGroundSphere.position.set(0, -24, 2);
                addWorldTrees();
            }
    
            function addLight() {
                scene.add(new THREE.HemisphereLight(0xfffafa, 0x000000, .9));
                sun = new THREE.DirectionalLight(0xcdc1c5, 0.9);
                sun.position.set(12, 6, -7);
                sun.castShadow = true;
                scene.add(sun);
            }
    
            function createTree() {
                var sides = 8, tiers = 6;
                var scalarMultiplier = (Math.random() * 0.15) + 0.05;
                var treeGeometry = new THREE.ConeGeometry(0.5, 1, sides, tiers);
                var treeMaterial = new THREE.MeshStandardMaterial({ color: 0x33ff33, flatShading: true });
                
                const pos = treeGeometry.attributes.position;
                blowUpTree(pos, sides, 0, scalarMultiplier);
                tightenTree(pos, sides, 1);
                blowUpTree(pos, sides, 2, scalarMultiplier * 1.1, true);
                tightenTree(pos, sides, 3);
                blowUpTree(pos, sides, 4, scalarMultiplier * 1.2);
                tightenTree(pos, sides, 5);
                pos.needsUpdate = true;
    
                var treeTop = new THREE.Mesh(treeGeometry, treeMaterial);
                treeTop.castShadow = true;
                treeTop.position.y = 0.9;
                treeTop.rotation.y = Math.random() * Math.PI;
                var treeTrunk = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.5), new THREE.MeshStandardMaterial({ color: 0x886633, flatShading: true }));
                treeTrunk.position.y = 0.25;
                var tree = new THREE.Object3D();
                tree.add(treeTrunk);
                tree.add(treeTop);
                return tree;
            }
    
            function blowUpTree(pos, sides, currentTier, scalarMultiplier, odd) {
                for (var i = 0; i < sides; i++) {
                    var vertexIndex = (currentTier * sides) + 1;
                    var idx = i + vertexIndex;
                    let vX = pos.getX(idx);
                    let vY = pos.getY(idx);
                    let vZ = pos.getZ(idx);
                    let vertexVector = new THREE.Vector3(vX, vY, vZ);
                    let midPointVector = new THREE.Vector3(0, vY, 0);
                    let offset = vertexVector.clone().sub(midPointVector);
                    if (odd) {
                        if (i % 2 === 0) offset.normalize().multiplyScalar(scalarMultiplier / 6);
                        else {
                            offset.normalize().multiplyScalar(scalarMultiplier);
                            let nextY = pos.getY(idx + sides);
                            pos.setY(idx, nextY + 0.05);
                        }
                    } else {
                        if (i % 2 !== 0) offset.normalize().multiplyScalar(scalarMultiplier / 6);
                        else {
                            offset.normalize().multiplyScalar(scalarMultiplier);
                            let nextY = pos.getY(idx + sides);
                            pos.setY(idx, nextY + 0.05);
                        }
                    }
                    let finalVec = vertexVector.add(offset);
                    pos.setXYZ(idx, finalVec.x, finalVec.y, finalVec.z);
                }
            }
    
            function tightenTree(pos, sides, currentTier) {
                for (var i = 0; i < sides; i++) {
                    var vertexIndex = (currentTier * sides) + 1;
                    var idx = i + vertexIndex;
                    let vX = pos.getX(idx);
                    let vY = pos.getY(idx);
                    let vZ = pos.getZ(idx);
                    let vertexVector = new THREE.Vector3(vX, vY, vZ);
                    let midPointVector = new THREE.Vector3(0, vY, 0);
                    let offset = vertexVector.clone().sub(midPointVector);
                    offset.normalize().multiplyScalar(0.06);
                    let finalVec = vertexVector.sub(offset);
                    pos.setXYZ(idx, finalVec.x, finalVec.y, finalVec.z);
                }
            }
    
            function addWorldTrees() {
                var numTrees = 36, gap = 6.28 / 36;
                for (var i = 0; i < numTrees; i++) {
                    addTree(false, i * gap, true);
                    addTree(false, i * gap, false);
                }
            }
    
            function addTree(inPath, row, isLeft) {
                var newTree;
                if (inPath) {
                    if (treesPool.length == 0) return;
                    newTree = treesPool.pop();
                    newTree.visible = true;
                    
                    // Reset rotations in case it was a fallen tree
                    newTree.rotation.set(0,0,0);
    
                    // Randomly decide if this tree falls (30% chance)
                    newTree.userData.isFalling = Math.random() < 0.3;
                    newTree.userData.fallRotation = 0;
    
                    treesInPath.push(newTree);
                    sphericalHelper.set(worldRadius - 0.3, pathAngleValues[row], -rollingGroundSphere.rotation.x + 4);
                } else {
                    newTree = createTree();
                    var forestAreaAngle = isLeft ? 1.68 + Math.random() * 0.1 : 1.46 - Math.random() * 0.1;
                    sphericalHelper.set(worldRadius - 0.3, forestAreaAngle, row);
                }
                newTree.position.setFromSpherical(sphericalHelper);
                var rollingGroundVector = rollingGroundSphere.position.clone().normalize();
                var treeVector = newTree.position.clone().normalize();
                newTree.quaternion.setFromUnitVectors(treeVector, rollingGroundVector);
                newTree.rotation.x += (Math.random() * (2 * Math.PI / 10)) + -Math.PI / 10;
                rollingGroundSphere.add(newTree);
            }
    
            function addExplosion() {
                particleGeometry = new THREE.BufferGeometry();
                const positions = new Float32Array(particleCount * 3);
                particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
                var pMaterial = new THREE.PointsMaterial({ color: 0xfffafa, size: 0.2 });
                particles = new THREE.Points(particleGeometry, pMaterial);
                scene.add(particles);
                particles.visible = false;
            }
    
            function explode() {
                particles.position.set(heroSphere.position.x, 2, 4.8);
                const pos = particleGeometry.attributes.position;
                for (var i = 0; i < particleCount; i++) {
                    pos.setXYZ(i, -0.2 + Math.random() * 0.4, -0.2 + Math.random() * 0.4, -0.2 + Math.random() * 0.4);
                }
                pos.needsUpdate = true;
                explosionPower = 1.07;
                particles.visible = true;
            }
    
            function doTreeLogic() {
                var treePos = new THREE.Vector3(), treesToRemove = [];
                treesInPath.forEach(function (oneTree) {
                    treePos.setFromMatrixPosition(oneTree.matrixWorld);
                    
                    // Animation: If it's a falling tree, tip it over as it gets closer
                    if (oneTree.userData.isFalling) {
                        if (oneTree.userData.fallRotation < Math.PI / 2) {
                            oneTree.userData.fallRotation += 0.05;
                            oneTree.rotation.z = oneTree.userData.fallRotation;
                        }
                    }
    
                    if (treePos.z > 6 && oneTree.visible) {
                        treesToRemove.push(oneTree);
                    } else if (treePos.distanceTo(heroSphere.position) <= 0.6) {
                        
                        // SPECIAL COLLISION: If the tree is fallen, you can jump over it!
                        if (oneTree.userData.isFalling && heroSphere.position.y > heroBaseY + 0.2) {
                            // Safe! User jumped over the fallen tree
                        } else {
                            hasCollided = true;
                            explode();
                            gameOver();
                        }
                    }
                });
                treesToRemove.forEach(function (element) {
                    var fromWhere = treesInPath.indexOf(element);
                    treesInPath.splice(fromWhere, 1);
                    treesPool.push(element);
                    element.visible = false;
                });
            }
    
            function doExplosionLogic() {
                if (!particles.visible) return;
                const pos = particleGeometry.attributes.position;
                for (var i = 0; i < particleCount; i++) {
                    let x = pos.getX(i) * explosionPower;
                    let y = pos.getY(i) * explosionPower;
                    let z = pos.getZ(i) * explosionPower;
                    pos.setXYZ(i, x, y, z);
                }
                pos.needsUpdate = true;
                if (explosionPower > 1.005) explosionPower -= 0.001;
                else particles.visible = false;
            }
    
            function onWindowResize() {
                sceneHeight = window.innerHeight;
                sceneWidth = window.innerWidth;
                renderer.setSize(sceneWidth, sceneHeight);
                camera.aspect = sceneWidth / sceneHeight;
                camera.updateProjectionMatrix();
            } 
        </script>
    </body>
    </html>