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!!