debug debug debug 😓 close but still not happy!!!
Category: GLB
-
Optimization of Kinetic Continuity in 3D Parkour Environments: Swing and Landing Mechanics
In game development, “feel” is everything. When building a parkour engine, movement must feel continuous. If a player performs an action and the character suddenly “snaps” or “teleports” from one height to another, the immersion is shattered.
Recently, while developing a parkour prototype using Three.js, I encountered a critical failure in the Swing Mechanic. This article documents the evolution of that feature: a journey from a rigid, ground-only movement to a fully realized, height-aware physics system.
Phase 1: Solving Input Rigidity
Initially, the swing mechanic was functionally primitive. The system was logically bound to a single state: the player had to be standing on the floor to interact with the environment.
The Problem: State-Dependent Input
The input handler restricted the KeyF (Swing) action inside a conditional block that only checked if the player’s state was ‘ground’. This created a “dead zone” where jumping or falling made the mechanic unreachable.
The Problematic Code:
code JavaScript
// ❌ THE PROBLEM: Input is locked to the 'ground' state function onPress(code) { if (state === 'ground') { switch (code) { case 'KeyE': tryHang(); break; case 'KeyF': trySwing(); break; // Logic fails if state is 'air' } } }The Fix: Universal Input Triggering
To achieve true parkour flow, a player must be able to grab a bar mid-air. I refactored the input logic to decouple the swing trigger from the grounded state.
The Optimized Code:
code JavaScript
// ✅ THE FIX: Decoupling input from the 'ground' state function onPress(code) { if (state === 'ground') { /* ...ground-only actions... */ } // The swing trigger is now state-agnostic if (code === 'KeyF') { trySwing(); // Works during 'ground' OR 'air' states } }
Phase 2: The “Teleportation Pop” Bug
Once the player could grab the bar from the air, a much more jarring issue emerged: Kinematic Discontinuity. If a player jumped from a high building and grabbed a bar, they would instantly “teleport” to the floor the moment they touched the bar.
The Problem: Hard-Coded Coordinate Snapping
The bug originated in the trySwing function. Upon engagement, the system was hard-coding the player’s vertical position (Y_pos) to 0 (the ground plane).
The Problematic Code:
code JavaScript
// ❌ THE PROBLEM: Hard-coded ground snap function trySwing() { // ... logic to find bar ... // This '0' forced the player to the floor instantly. pos.set(targetX, 0, targetZ); state = 'swing'; }The resulting visual “jitter” followed this sequence:
- Grab: Player engages the bar mid-air.
- Teleport: Character instantly snaps to
Y=0Y=0(the ground). - Correction: The physics engine realizes the player is actually above a block.
- Pop: The character is violently “yanked” back up to the block.
Phase 3: The Advanced Physics Solution
To achieve a professional “feel,” I implemented a three-tier optimization strategy: Height Memory, Sinusoidal Interpolation, and Smart Landing.
Step 1: Temporal Height Memory
We moved away from hard-coded values by implementing Height Memory. The system now captures the player’s exact vertical position (Y_start) at the moment of engagement.
The Optimized Code:
code JavaScript
// ✅ Inside trySwing() swingStartY = pos.y; // Capture the exact height of the jump/fall // Snap to the BAR height, not the floor, preserving verticality pos.set(targetX, bar.y, targetZ);Step 2: Sinusoidal Path Interpolation
A natural swing follows an arc, not a straight line. I used Linear Interpolation (LERP) to transition between the starting height (Y_start) and the landing height (Y_land), combined with a Sine Wave to simulate gravitational momentum.
The Optimized Code:
code JavaScript
// ✅ THE FIX: Smooth height interpolation via Sinusoidal Arc function updateSwing() { const t = clamp(activeAction.time / duration, 0, 1); const u = t * t * (3 - 2 * t); // Smoothstep easing // LERP: Move from captured start height to predicted landing height const heightTransition = THREE.MathUtils.lerp(swingStartY, swingLandY, u); // Sinusoidal Arc: Adds a "swing boost" to create a physical curve const arcHeight = Math.max(swingStartY, swingLandY, 1.0) * 0.5; pos.y = heightTransition + arcHeight * Math.sin(Math.PI * u); }Step 3: Smart Landing (Conditional State Switching)
Finally, we addressed the “end-of-animation” transition. If the swing ends over a platform, we snap to it. If the swing ends in mid-air, we must avoid snapping to the ground and instead transition the player into a natural falling state.
The Optimized Code:
code JavaScript
// ✅ THE FIX: Conditional landing logic function finishSwing() { if (swingLandY > 0) { // Scenario A: Surface detected -> Transition to Grounded pos.x = swingTargetPos.x; pos.y = swingLandY; state = 'ground'; } else { // Scenario B: No surface detected -> Transition to Airborne (Natural Fall) state = 'air'; airMode = 'fall'; } }
Conclusion
By moving away from hard-coded coordinate snapping and embracing a State-Based Physics model, we transformed a glitchy, broken mechanic into a fluid movement system.
Key Takeaway for Developers: In any physics-based engine, always respect the player’s current momentum and position. Never force a coordinate unless you have mathematically accounted for the transition from the previous state.
Keywords: Three.js, Game Development, Physics Engine, JavaScript, Parkour Game, Bug Fixing, Lerp, Kinematics, Animation.
-
.fbx – .glb – compression
# 1. Rename all files to replace spaces with underscores and lowercase them for f in *' '*.fbx *' '*.fbm; do [ -e "$f" ] || continue new_name=$(echo "$f" | tr '[:upper:]' '[:lower:]' | tr ' ' '_') mv "$f" "$new_name" done # 2. Convert all raw .fbx files to .glb for f in *.fbx; do [ -e "$f" ] || continue base="${f%.fbx}" fbx2gltf -i "$f" -b -o "${base}.glb" done # 3. Compress and optimize all .glb files using your pipeline for f in *.glb; do [[ "$f" == *"_clean.glb" ]] && continue [ -e "$f" ] || continue base="${f%.glb}" echo "Optimizing $f to high-quality ${base}_clean.glb..." gltf-transform resize "$f" "${base}_clean.glb" --width 512 --height 512 && \ gltf-transform prune "${base}_clean.glb" "${base}_clean.glb" && \ gltf-transform webp "${base}_clean.glb" "${base}_clean.glb" --slots baseColor && \ gltf-transform draco "${base}_clean.glb" "${base}_clean.glb" --quantize-position 11 --quantize-texcoord 12 done # 2. Re-run your compression pipeline for f in *.glb; do [[ "$f" == *"_clean.glb" ]] && continue [ -e "$f" ] || continue base="${f%.glb}" echo "Optimizing $f to high-quality ${base}_clean.glb..." gltf-transform resize "$f" "${base}_clean.glb" --width 512 --height 512 && \ gltf-transform prune "${base}_clean.glb" "${base}_clean.glb" && \ gltf-transform webp "${base}_clean.glb" "${base}_clean.glb" --slots baseColor && \ gltf-transform draco "${base}_clean.glb" "${base}_clean.glb" --quantize-position 11 --quantize-texcoord 12 done # 3. delete all beside _clean.glb shopt -s extglob && rm -rf !(*_clean.glb) -
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>Expandthis is just base on https://mero.live/build/battle old stuff. free time!!
-
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 doneUse code with caution.
-
World – Hot Air Balloon
The Art of the Drift
Engineering a Relaxing Hot Air Balloon System in Three.js
Date: September 18, 2026
Project: World Simulation
Focus: Vehicle Physics, Camera Immersion, and Collision Resolution
1. Abstract
The objective was to implement a Hot Air Balloon vehicle that felt “weightless” and “atmospheric.” Initial attempts resulted in “snappy” movement akin to a ground vehicle. Through iterative testing, we moved from Kinematic Movement (instant) to Momentum-Based Physics (drifting), while solving critical bugs regarding spawn-teleportation and collision locking.
2. The Evolution: What Went Wrong & What Went Right
❌ The “Bad” (Initial Pain Points)
- The “Car” Feel: Initially, the balloon stopped the millisecond the key was released. It felt like a floating car, not a balloon.
- The “Jump” Bug: Upon entering the balloon, the code forced a +3 height increase. This looked like a “glitchy jump” and ruined the smooth entry.
- The “Stuck” Paradox: When exiting in mid-air, the player was teleported instantly to the ground. If a building was beneath them, they spawned inside the mesh, triggering the isBlocked function and freezing the player in place.
- Speed Issues: Initial momentum was too high, making the balloon feel like a jet engine rather than a peaceful drift.
✅ The “Good” (The Wins)
- Momentum Flow: Implementing a velocity vector allowed the balloon to “glide.” Now, it takes time to speed up and time to slow down.
- The Aerial Perspective: Switching to a 45-degree diagonal camera shifted the experience from “playing a game” to “viewing a landscape.”
- Natural Exit: By removing the forced ground-snap, players now fall naturally from the balloon, adding a sense of gravity and danger.
3. Technical Implementation (The “Golden” Code)
To achieve the final feel, we modified two core systems: the Vehicle Logic (index.html) and the Object Classification (build.js).
A. The Physics Engine (index.html)
We replaced instant position updates with a Velocity-Friction Model.
The Constants:
code JavaScript
// Ultra-Slow Floating Physics const BALLOON_ACCEL = 0.5; // Slow start, prevents "snapping" const BALLOON_FRICTION = 0.998; // Near-zero friction = infinite glide const BALLOON_MAX_SPEED = 4.0; // Capped for a relaxing pace const BALLOON_LIFT_SPEED = 5.0; // Constant lift for the Q keyThe Logic Loop:
code JavaScript
// Inside animate() -> if (isBalloon) const forward = new THREE.Vector3(0, 0, 1).applyQuaternion(activeControl.quaternion); // Horizontal Momentum (W/S) if (keys['w']) balloonVelocity.addScaledVector(forward, BALLOON_ACCEL * delta); if (keys['s']) balloonVelocity.addScaledVector(forward, -BALLOON_ACCEL * delta); // Direct Lift (Q) - No momentum, just constant rise if (keys['q']) activeControl.position.y += BALLOON_LIFT_SPEED * delta; // Downward Drift (E) - Uses momentum to "sink" if (keys['e']) balloonVelocityY -= BALLOON_ACCEL * delta; // Apply Glide Friction balloonVelocity.multiplyScalar(BALLOON_FRICTION); balloonVelocityY *= BALLOON_FRICTION; // Final Position Update activeControl.position.add(balloonVelocity); activeControl.position.y += balloonVelocityY;B. The Cinematic Camera (index.html)
To simulate “Sunlight at 9 AM,” we positioned the camera at a high diagonal offset.
code JavaScript
// Specialized Balloon Camera View if (isDriving && currentVehicle && currentVehicle.userData && currentVehicle.userData.isBalloon) { const camDist = 25; // Offset camera 45 degrees top-down and behind const offset = new THREE.Vector3(camDist, camDist, camDist).applyQuaternion(activeControl.quaternion); _vLookTarget.copy(activeControl.position).add(offset); camera.position.lerp(_vLookTarget, delta * 5); camera.lookAt(activeControl.position); }C. The Build Classification (build.js)
For the physics to know it’s a balloon and not a car, we added metadata during the GLB load.
code JavaScript
// Inside createInScene(data) const isBalloon = nameLower.includes('balloon') || nameLower.includes('hot-air'); if (isBalloon) { obj.userData.isVehicle = true; obj.userData.isBalloon = true; // This flag triggers the momentum physics }
4. Conclusion & Final Tuning
The “Perfect Drift” was found by balancing Acceleration vs. Friction. By lowering acceleration to 0.5 and raising friction to 0.998, we achieved a state of “perpetual flow.”
The removal of the nearest.position.y += 3 line in toggleVehicle finally allowed the balloon to sit naturally at ground level 0, creating a seamless transition from walking to flying.
Current State: 🟢 Stable | Feeling: ☁️ Ethereal | Movement: 🌊 Fluid
-
gltf-transform – glb compress
Alternatively (The One-Line “Optimize” Command)
If you want to do it all in a single fast sweep,
gltf-transformhas a built-in macro command that combines most of these steps automatically: [1]bash
for f in *_compressed.glb; do gltf-transform optimize "$f" "${f%_compressed.glb}_min.glb" --texture-compress webp done -
gltf-transform : FBX Draco-compress glb

https://cesium.com/blog/2018/04/09/draco-compression To compress
run_output.glb(and your other generated GLB files), you can apply Draco mesh compression and animation resampling directly from your Debian terminal.Step 1: Compress the File You Just Created
Run
@gltf-transform/clidirectly onrun_output.glb:Bash
gltf-transform draco run_output.glb run_compressed.glbTo verify the file size reduction, check the before and after sizes:
Bash
ls -lh run_output.glb run_compressed.glbStep 2: Compress Animation Keyframes (Optional)
Draco compresses 3D geometry (vertices, normals, UVs), but Mixamo animations often contain thousands of redundant keyframe tracks. To compress the animation keyframe data as well, run
resample:Bash
gltf-transform resample run_compressed.glb run_final.glbStep 3: Compress All FBX Files in Your Folder at Once
To convert and Draco-compress every
.fbxfile in yourbattledirectory in a single loop, run:Bash
for f in *.fbx; do fbx2gltf -i "$f" -o "${f%.fbx}.glb" gltf-transform draco "${f%.fbx}.glb" "${f%.fbx}_compressed.glb" rm "${f%.fbx}.glb" doneThis will produce optimized
_compressed.glbversions of all your animations. -
World – 6th THUNDER MODE – Chaos from the Sky – Building a Modular Lightning System
https://world.officialstupid.me



Our social 3D world was peaceful—too peaceful. To add some excitement, we decided to implement Thunder Mode, a survival challenge where players dodge procedurally generated lightning strikes. However, adding a complex “game mode” to a social engine often leads to “spaghetti code” and performance death.
Here is how we built a high-performance, modular weather system that creates zero load on the server and stays silky smooth on mobile.
1. The Strategy: Complete Modularity
The biggest mistake in web game dev is cramming every new feature into the main animate() loop. Instead, we built thunder.js as a Self-Contained Module.
When you load the script, it automatically injects its own CSS, creates its own HTML overlays (Health bars, toggle buttons), and manages its own internal state. If we remove the script, the game still runs perfectly.
The “Zero-Server-Load” Logic:
We don’t sync lightning positions through the server. Instead, each client calculates its own “personal” lightning. This means the server only handles player positions, while the heavy math happens locally on the user’s device.
2. Visual Optimization: The “Double-Layer” Bolt
Initially, we used full-screen blue flashes and camera shakes to simulate thunder. Result: FPS dropped to 10 on mobile. CSS background changes trigger “layout reflows,” which are expensive.
We pivoted to a purely 3D visual approach using a Two-Layer Bolt system:
- The Core: A thin, bright white TubeGeometry.
- The Glow: A thicker blue tube using AdditiveBlending.
code JavaScript
// High-performance bolt geometry logic createBolt(x, z) { const pts = generateZigZagPoints(x, z); const curve = new THREE.CatmullRomCurve3(pts); // Core (White) - 4 radial segments (square tube) const core = new THREE.Mesh(new THREE.TubeGeometry(curve, 16, 0.04, 4), coreMat); // Glow (Blue + Additive Blending) - 6 radial segments (hexagonal tube) const glow = new THREE.Mesh(new THREE.TubeGeometry(curve, 16, 0.2, 6), glowMat); boltGroup.add(glow, core); return boltGroup; }
3. Progressive Difficulty
A static game is a boring game. We implemented a difficulty scalar that increases every second you stay alive. This variable controls four things simultaneously:
- Spawn Rate: From one bolt every 2 seconds down to four bolts per second.
- Warning Time: The red “danger zone” circle stays on the ground for less time.
- Max Bolts: Increases the cap on how many bolts can be on screen at once (up to 15).
- Spawn Radius: As you survive, lightning fills a wider area around you.
4. Integrating GLB Death Animations
Manual character rotation looked “fake.” We wanted to use the professional animations built into our GLB models.
The Challenge: If you simply play the death animation in a loop, the character keeps dying and standing up repeatedly. To fix this, we implemented a State-Change Check.
code JavaScript
// Inside the main animation loop const isHit = thunder.isDead || thunder.isKnockedDown; if (isHit) { // Only trigger the animation ONCE when the state changes if (lastAnim !== 'death') { deathAction.reset().setLoop(THREE.LoopOnce).play(); deathAction.clampWhenFinished = true; // Stay on the last frame } anim = 'death'; } else { anim = 'idle'; } lastAnim = anim;
5. Framing the Chaos: UI Positioning and Depth
To improve the user experience, we made two major aesthetic adjustments:
- The 40% Rule: We adjusted the camera’s look at target to Y=3.5. This tilts the view slightly upward, pushing the character selection slider to the bottom 40% of the screen. This keeps the characters visible but leaves the top of the screen clear for the environment.
- Atmospheric Clarity: We thinned the fog by changing the far distance from 75 to 150. This makes the world feel larger and gives players more time to see the environment while they are dodging strikes.
- Expanded Roster: We expanded the character pool, supporting a full lineup of models from 01.glb to 09.glb, giving players more ways to express themselves before the storm hits.
6. The “Wasted” Loop: Delayed Restart
Survival games need a moment of impact. When a player finally loses all health, we don’t show the menu immediately. We added a 2-second delay where the camera lingers on the fallen character before the “START AGAIN” button fades in. This makes death feel meaningful and gives the player a moment to breathe before jumping back in.
The Result
By keeping the code modular, we managed to:
- Maintain 60 FPS: By avoiding CSS-based screen effects and using low-poly 3-sided tubes for bolts.
- Improve Visuals: The additive blue glow looks far more “electrifying” than a simple white line.
- Keep the Engine Clean: The main index.html only needs one or two lines to “talk” to the Thunder System.
Next time you see a red ring on the floor, you have exactly 0.8 seconds to move—Good Luck!
https://aistudio.google.comMORE NEW GLB – https://pixabay.com/3d-models/search/glb/
-
world – 5th – The “Invisible” Mobile Controller – Gestures for 3D Worlds
Moving a 3D character on a smartphone usually means cluttering the screen with ugly joysticks and transparent buttons. For our multiplayer project, we wanted something better. We built an Invisible Gesture Controller that turns the entire screen into a touch-sensitive pad.
The Strategy: Mapping Keys to Gestures
Our game engine already listens for specific keys like W (Walk), Shift (Run), and Space (Jump). Instead of rewriting the engine, we built a “Touch Wrapper” that translates finger movements into these virtual key presses.
1. Browser Normalization (CSS)
To make a web game feel like a native app, you have to kill the browser’s default behaviors. Without this, the phone will try to zoom or “select text” while you are trying to play.
code CSS
/* Add this to stop mobile browser interference */ body { touch-action: none; /* Disable pinch-to-zoom and scroll */ user-select: none; /* Disable text highlighting */ -webkit-touch-callout: none; /* Disable long-press menus */ -webkit-tap-highlight-color: transparent; /* Remove blue tap boxes */ }2. The Touch Logic (JavaScript)
This is the heart of the mobile support. It handles rotation, toggle-walking, sprinting, and vertical swipes for actions.
code JavaScript
let touchStartX = 0, touchStartY = 0, lastTapTime = 0; function setupMobileControls() { // CAPTURE START POSITION window.addEventListener('touchstart', (e) => { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; }, { passive: false }); // ROTATION: Horizontal slide window.addEventListener('touchmove', (e) => { if (!isStarted || !localPlayer) return; let dx = e.touches[0].clientX - touchStartX; if (Math.abs(dx) > 5) { localPlayer.rotation.y -= dx * 0.01; // Rotate character touchStartX = e.touches[0].clientX; // Reset baseline } e.preventDefault(); // Stop page "bounce" }, { passive: false }); // TAPS & SWIPES: Logic for Walk, Run, Jump, and Camera window.addEventListener('touchend', (e) => { let dx = e.changedTouches[0].clientX - touchStartX; let dy = e.changedTouches[0].clientY - touchStartY; let now = Date.now(); let isDoubleTap = (now - lastTapTime) < 300; if (isStarted) { // SWIPE UP -> JUMP (Space) if (dy < -50 && Math.abs(dx) < 50) { keys[' '] = true; setTimeout(() => keys[' '] = false, 100); } // SWIPE DOWN -> CAMERA VIEW (V) else if (dy > 50 && Math.abs(dx) < 50) { toggleCameraView(); } // TAP LOGIC else if (Math.abs(dx) < 10 && Math.abs(dy) < 10) { if (isDoubleTap) { // Double Tap: Toggle between Running and Walking if (keys['shift']) { keys['shift'] = false; keys['w'] = true; } else { keys['w'] = true; keys['shift'] = true; } } else { // Single Tap: Start/Stop Walking if (keys['w'] || keys['shift']) { keys['w'] = false; keys['shift'] = false; // Stop } else { keys['w'] = true; // Start Walk } } lastTapTime = now; } } }, { passive: false }); }How the Gestures Work:
- Rotation: Sliding your finger left or right updates the localPlayer.rotation.y. It’s sensitive and smooth, allowing for quick 360-degree turns.
- The “Tap to Move” Toggle: On a keyboard, you hold ‘W’. On mobile, holding your finger down is tiring. We changed it to a Toggle: Tap once to start walking, tap again to stop.
- Double-Tap to Sprint: Just like Minecraft or other mobile titles, a quick double-tap switches the state from Walking to Running (Shift).
- Swipe Down for Selfie: We mapped the Camera Flip (V) to a downward swipe. It’s an intuitive gesture for switching perspectives.
- Swipe Up to Jump: A vertical flick triggers the jump physics instantly.
The Result
By using gestures instead of buttons, we kept the screen 100% clean. This maximizes the immersion of the 3D world and makes the game feel like a premium mobile app rather than a website.
Next time you log in from your phone, try the swipe-down to see your character’s face!