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