A browser-based tool for choreographing combat animations without recompiling your game.
What This Tool Does
You have a set of player attack animations and a set of NPC reaction animations. You need to define, for each attack:
- Which NPC reaction(s) fire — one or more, chosen randomly
- Where the NPC stands — per-attack distance in meters
- When the reaction fires — as a fraction of the attack clip (e.g. 60%)
- How fast the reaction plays — a speed multiplier
Doing this by editing JSON and refreshing the game is agonizing. This tool lets you do it visually, preview it live in 3D, and export the config as a single JSON file your game reads at runtime.
Feature List
| Feature | What it does |
|---|---|
| Auto-discovery + fallback | Loads GLBs from folder listing or a hardcoded list |
| Auto-classification | Files starting with NPC_ are NPC assets; everything else is a player attack |
| Blender-style editor | Bottom split-pane that keeps the 3D view visible |
| Live previews | Click ▶ on any row to play that clip on the 3D scene |
| Per-attack sliders | Range, hit timing, and NPC speed — all saved per attack |
| Auto-preview on release | Releasing a slider plays the result instantly |
| localStorage persistence | Pairings save automatically, survive refreshes |
| Schema migration | Old saved data auto-upgrades to newer formats |
| JSON export / import | The config contract between tool and game |
| Static minimum range | Slider can’t go below a value that would clip the models |
Architecture
text
┌─────────────────────────────────────────────────────────┐
│ PAIRING EDITOR (browser tool) │
│ │
│ • Loads GLBs, classifies player vs NPC │
│ • Bottom pane: attacks × NPCs │
│ • ▶ preview per row │
│ • + / × to link / unlink │
│ • 3 sliders per attack: range, hitAt, npcSpeed │
│ • Auto-saves to localStorage │
│ • ↙ Import / ↗ Export as JSON │
└──────────────────────────┬──────────────────────────────┘
│
▼ fight_pairings.json
┌─────────────────────────────────────────────────────────┐
│ GAME RUNTIME │
│ │
│ • Loads GLBs, reads pairings.json once │
│ • On attack: look up entry, position NPC, schedule │
│ reaction, play at tuned speed │
└─────────────────────────────────────────────────────────┘
The tool’s only job is to produce the JSON. Everything else — the UI, the storage, the previews — exists to make that JSON cheap to produce and easy to iterate.
Feature 1 — File Loading That Never Fails
Browsers can’t enumerate a folder. But a directory listing (like Python’s http.server generates) exposes <a href="file.glb"> links we can scrape. The problem: as soon as you add an index.html, the listing disappears.
Solution: hybrid loading. Try to scrape; merge the result with a hardcoded fallback list. The tool always has files to work with.
js
const FALLBACK_FILES = [
'Boxing_clean.glb',
'flying_kick_clean.glb',
'Headbutt_clean.glb',
'Kicking_clean.glb',
'leg_sweep_clean.glb',
'martelo_2_clean.glb',
'quad_punch_clean.glb',
'roundhouse_kick_clean.glb',
'surprise_uppercut_clean.glb',
'NPC_receive_punch_to_the_face_clean.glb',
'NPC_receive_stomach_uppercut_clean.glb',
'NPC_surprise_uppercut_clean.glb',
'NPC_taking_punch_clean.glb',
'NPC_knocked_out_clean.glb',
'NPC_standing_death_backward_01_clean.glb',
'NPC_standing_death_forward_01_clean.glb'
];
async function discoverFiles(){
try {
const res = await fetch(location.href, { cache: 'no-store' });
if (!res.ok) return [];
const text = await res.text();
const re = /href\s*=\s*["']([^"']+\.glb)["']/gi;
const found = new Set();
let m;
while ((m = re.exec(text)) !== null){
const name = decodeURIComponent(m[1]).split('/').pop();
if (name) found.add(name);
}
return [...found];
} catch { return []; }
}
/* In boot(): */
const discovered = await discoverFiles();
const merged = Array.from(new Set([...FALLBACK_FILES, ...discovered]))
.filter(n => /\.glb$/i.test(n));
Also dedupes foo.glb vs foo_clean.glb — keeping only the _clean one:
js
function dedupeClean(files){
const set = new Set(files);
return files.filter(f => {
const plain = f.replace(/_clean\.glb$/i, '.glb');
if (plain === f) return true;
return !set.has(plain);
});
}
Feature 2 — Player vs NPC Classification
Naming convention does all the work:
js
const idleFile = allFiles.find(f => /NPC_bouncing_fight_idle/i.test(f)) || null; const reactionFiles = allFiles.filter(f => /^NPC_/i.test(f) && f !== idleFile); const attackFiles = allFiles.filter(f => !/^NPC_/i.test(f));
NPC_*.glb→ NPC reaction poolNPC_bouncing_fight_idle_clean.glb→ special: NPC base mesh + idle loop- Everything else → player attack
Both loaders re-center their mesh on origin and reset the feet to Y=0:
js
function prepareCharacter(root){
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
root.position.x -= center.x;
root.position.z -= center.z;
root.position.y -= box.min.y;
root.updateMatrixWorld(true);
return size;
}
Feature 3 — The Blender-Inspired Pairing Editor
The editor opens from the bottom of the viewport. The 3D scene stays visible above it. This is the single most important UX decision — every tuning action has immediate visual feedback.
text
┌──────────┬──────────────────────────────────────┐ │ SIDEBAR │ 3D VIEWPORT │ ├──────────┴──────────────────────────────────────┤ │ PAIRING EDITOR [× close] │ │ ───────────────────────────────────────────── │ │ ATTACKS │ NPC POOL │ │ ▶ Boxing [2] │ ▶ Block + │ │ ▶ Kicking [1] │ ▶ Face Hit + │ └─────────────────────────────────────────────────┘
The panel is one <section> in the layout, toggled by a class:
js
function openPairing(){
pairingEl.classList.remove('hidden');
pairSelectedIndex = currentAttackIndex;
renderPairingAttacks();
renderPairingNpcs();
renderPairingFoot();
}
Each attack track shows:
- ▶ — preview button
- Name — click to select this attack
- Badge — how many NPC reactions are assigned
- Timeline bar — clip duration with a pink marker at the hit frame
- Chips — one per assigned NPC, with an × to remove
- Sliders — only shown when this attack is selected
Feature 4 — Live Previews
Any row can be previewed by clicking ▶. This plays the clip on the 3D scene without committing anything.
js
function previewAttack(index){
const c = playerClips[index];
previewing = true; // suppress auto-reactions
attacking = false;
/* cancel any in-flight attack state */
clearTimeout(attackEndTimer);
clearTimeout(hitTimer);
/* move NPC to this attack's tuned range */
moveNpcTo(c.file);
playerMixer.stopAllAction();
playerAction = playerMixer.clipAction(c.clip);
playerAction.reset();
playerAction.setEffectiveTimeScale(speed);
playerAction.setLoop(THREE.LoopOnce, 1);
playerAction.clampWhenFinished = true;
playerAction.play();
setPlayingIndicator(-1, index);
previewEndTimer = setTimeout(() => {
previewing = false;
setPlayingIndicator(-1);
}, (c.clip.duration || 1) * 1000 / speed + 200);
}
function previewNpc(npcFile, overrideSpeed = null){
const r = npcReactionClips.find(x => x.file === npcFile);
if (!r || !npcMixer) return;
const chosenAttackFile = playerClips[pairSelectedIndex]?.file
|| playerClips[currentAttackIndex]?.file;
const spdMul = overrideSpeed ?? npcSpeedFor(chosenAttackFile);
if (npcIdleAction) npcIdleAction.fadeOut(0.12);
if (npcReactionAction){ npcReactionAction.stop(); npcReactionAction = null; }
npcReactionAction = npcMixer.clipAction(r.clip);
npcReactionAction.reset();
npcReactionAction.setEffectiveTimeScale(speed * spdMul);
npcReactionAction.setLoop(THREE.LoopOnce, 1);
npcReactionAction.clampWhenFinished = true;
npcReactionAction.fadeIn(0.1).play();
const dur = (r.clip.duration || 1) * 1000 / (speed * spdMul);
reactionEndTimer = setTimeout(() => {
if (!npcReactionAction) return;
npcReactionAction.fadeOut(0.2);
if (npcIdleAction){
npcIdleAction.reset();
npcIdleAction.setEffectiveTimeScale(speed);
npcIdleAction.setLoop(THREE.LoopRepeat, Infinity);
npcIdleAction.fadeIn(0.2).play();
}
npcReactionAction = null;
setPlayingIndicator(-1);
}, dur);
}
The currently-playing row highlights red via setPlayingIndicator:
js
function setPlayingIndicator(npcFile, attackIndex){
document.querySelectorAll('.atk-track').forEach((el, i) => {
el.classList.toggle('playing', i === attackIndex);
});
document.querySelectorAll('.npc-pool-row').forEach(el => {
el.classList.toggle('playing', el.dataset.file === npcFile);
});
}
Feature 5 — Per-Attack Metadata: Three Sliders
Each attack stores three tunables:
| Slider | Range | Meaning |
|---|---|---|
| ↔ Range | 0.35 – 4.0 m | Where the NPC stands |
| 🎯 Hit At | 5% – 95% | When the reaction fires (fraction of clip) |
| ⏱ Speed | 0.5× – 2.5× | Reaction playback speed |
Defaults live at the top of the script:
js
const DEFAULT_RANGE = 1.6; const MIN_RANGE = 0.5; const MAX_RANGE = 4.0; const DEFAULT_NPC_SPEED = 1.0; const MIN_NPC_SPEED = 0.5; const MAX_NPC_SPEED = 2.5; const DEFAULT_HIT_AT = 0.60; const MIN_HIT_AT = 0.05; const MAX_HIT_AT = 0.95;
Only the selected attack renders its sliders, keeping the panel clean:
js
if (isSel){
/* Range slider */
const rangeWrap = document.createElement('div');
rangeWrap.className = 'atk-range first';
const rInput = document.createElement('input');
rInput.type = 'range';
rInput.min = Math.max(MIN_RANGE, minSeparation); // ← collision floor
rInput.max = MAX_RANGE;
rInput.step = 0.05;
rInput.value = range;
rInput.addEventListener('input', () => {
const v = parseFloat(rInput.value);
setRangeFor(c.file, v);
if (playerClips[currentAttackIndex]?.file === c.file){
npcBaseTargetZ = v;
npcTargetZ = v;
updateDistanceGuide();
}
updateStatusForAttack(c);
});
/* ...append to track... */
/* Hit timing slider */
const tInput = document.createElement('input');
tInput.type = 'range';
tInput.min = MIN_HIT_AT;
tInput.max = MAX_HIT_AT;
tInput.step = 0.01;
tInput.value = hitAt;
tInput.addEventListener('input', () => {
const v = parseFloat(tInput.value);
setHitAtFor(c.file, v);
marker.style.left = (v * 100) + '%'; // live timeline marker
});
/* auto-play on release so the user *sees* the new timing */
tInput.addEventListener('pointerup', () => previewAttack(i));
/* Speed slider */
const sInput = document.createElement('input');
sInput.type = 'range';
sInput.min = MIN_NPC_SPEED;
sInput.max = MAX_NPC_SPEED;
sInput.step = 0.05;
sInput.value = npcSpd;
sInput.addEventListener('input', () => {
const v = parseFloat(sInput.value);
setNpcSpeedFor(c.file, v);
});
sInput.addEventListener('pointerup', () => {
const r = reactionsFor(c.file);
if (!r.length) return;
const pick = r[Math.floor(Math.random() * r.length)];
previewNpc(pick, npcSpeedFor(c.file));
});
}
The auto-preview on release is the single biggest usability win. No “apply” button, no separate “test” button — you drag, let go, and see the result.
Feature 6 — Hit Timing in the Attack Loop
When the player actually attacks (not a preview), the hit timer uses the attack’s hitAt:
js
function triggerAttack(){
if (!playerClips.length || attacking) return;
attacking = true; previewing = false;
btnFight.disabled = true;
const entry = playerClips[currentAttackIndex];
const clip = entry.clip;
const duration = clip.duration || 1;
const hitAt = hitAtFor(entry.file); // ← per-attack
if (playerRoot) playerRoot.position.x = playerRoot.position.z = 0;
playerMixer.stopAllAction();
playerAction = playerMixer.clipAction(clip);
playerAction.reset();
playerAction.setEffectiveTimeScale(speed);
playerAction.setLoop(THREE.LoopOnce, 1);
playerAction.clampWhenFinished = true;
playerAction.play();
clearTimeout(hitTimer);
hitTimer = setTimeout(
() => fireNpcReaction(entry.file),
duration * hitAt * 1000 / speed
);
clearTimeout(attackEndTimer);
attackEndTimer = setTimeout(() => {
attacking = false;
btnFight.disabled = false;
if (playerRoot) playerRoot.position.x = playerRoot.position.z = 0;
setPlayingIndicator(-1);
}, duration * 1000 / speed + 200);
}
The reaction picks a random NPC from the attack’s pool and plays it at the tuned speed:
js
function fireNpcReaction(attackFile){
if (!npcMixer || !npcReactionClips.length) return;
const pool = reactionsFor(attackFile);
const npcSpd = npcSpeedFor(attackFile);
let chosen = null;
if (pool.length){
const file = pool[Math.floor(Math.random() * pool.length)];
chosen = npcReactionClips.find(r => r.file === file);
}
if (!chosen){
/* fallback: any reaction */
chosen = npcReactionClips[Math.floor(Math.random() * npcReactionClips.length)];
}
if (!chosen) return;
if (npcIdleAction) npcIdleAction.fadeOut(0.12);
npcReactionAction = npcMixer.clipAction(chosen.clip);
npcReactionAction.reset();
npcReactionAction.setEffectiveTimeScale(speed * npcSpd);
npcReactionAction.setLoop(THREE.LoopOnce, 1);
npcReactionAction.clampWhenFinished = true;
npcReactionAction.fadeIn(0.1).play();
const dur = (chosen.clip.duration || 1) * 1000 / (speed * npcSpd);
reactionEndTimer = setTimeout(() => {
/* fade back to idle */
npcReactionAction.fadeOut(0.2);
npcIdleAction.reset().setEffectiveTimeScale(speed).fadeIn(0.2).play();
npcReactionAction = null;
}, dur);
}
Feature 7 — Persistence with Schema Migration
The mapping format changed four times during development. Every version bump ships with a migration function so old localStorage data never breaks.
js
const STORAGE_KEY = 'fight_viewer_mappings_v4';
function normalizeMappings(raw){
const out = {};
if (!raw || typeof raw !== 'object') return out;
for (const k of Object.keys(raw)){
const v = raw[k];
if (Array.isArray(v)){
/* v1 — bare array */
out[k] = {
reactions: v.slice(),
range: DEFAULT_RANGE,
npcSpeed: DEFAULT_NPC_SPEED,
hitAt: DEFAULT_HIT_AT
};
} else if (v && typeof v === 'object'){
out[k] = {
reactions: Array.isArray(v.reactions) ? v.reactions.slice() : [],
range: v.range ?? v.npcRange ?? DEFAULT_RANGE,
npcSpeed: v.npcSpeed ?? v.speed ?? DEFAULT_NPC_SPEED,
hitAt: v.hitAt ?? v.hitTiming ?? DEFAULT_HIT_AT
};
}
}
return out;
}
function loadMappings(){
try {
let raw = localStorage.getItem(STORAGE_KEY);
if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v3');
if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v2');
if (!raw) raw = localStorage.getItem('fight_viewer_mappings_v1');
if (!raw) return;
Object.assign(mappings, normalizeMappings(JSON.parse(raw)));
saveMappings(); // persist forward to v4
} catch (e){ console.warn('Bad mappings:', e); }
}
function saveMappings(){
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(mappings)); }
catch (e){ console.warn('Save failed:', e); }
}
Mappings save on every change — every +, every ×, every slider tick:
js
function addToAttack(attackFile, npcFile){
const m = mappingFor(attackFile);
if (!m.reactions.includes(npcFile)) m.reactions.push(npcFile);
saveMappings();
refreshAfterChange(attackFile);
}
function setRangeFor(f, v){ mappingFor(f).range = v; saveMappings(); }
function setNpcSpeedFor(f, v){ mappingFor(f).npcSpeed = v; saveMappings(); }
function setHitAtFor(f, v){ mappingFor(f).hitAt = v; saveMappings(); }
Feature 8 — JSON Export / Import
The exported JSON is the entire contract between tool and game.
Export — cleans up defaults, so only meaningful entries appear:
js
function openIoModal(mode){
if (mode === 'export'){
const clean = {};
for (const k of Object.keys(mappings).sort()){
const m = mappings[k];
const isDefault = !m.reactions.length
&& m.range === DEFAULT_RANGE
&& m.npcSpeed === DEFAULT_NPC_SPEED
&& m.hitAt === DEFAULT_HIT_AT;
if (isDefault) continue;
clean[k] = {
reactions: m.reactions,
npcRange: +m.range.toFixed(3),
hitAt: +m.hitAt.toFixed(3),
npcSpeed: +m.npcSpeed.toFixed(3)
};
}
ioText.value = JSON.stringify(clean, null, 2);
}
}
Import — validates, migrates, replaces:
js
function doApply(){
try {
const parsed = JSON.parse(ioText.value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)){
throw new Error('Not an object');
}
const next = normalizeMappings(parsed);
for (const k of Object.keys(mappings)) delete mappings[k];
Object.assign(mappings, next);
saveMappings();
refreshAfterChange(playerClips[pairSelectedIndex]?.file || '');
if (playerClips[currentAttackIndex]) moveNpcTo(playerClips[currentAttackIndex].file);
closeIoModal();
} catch (e){
ioStatus.textContent = '⚠ Invalid JSON: ' + e.message;
}
}
Example output:
json
{
"flying_kick_clean.glb": {
"reactions": ["NPC_knocked_out_clean.glb"],
"npcRange": 1.85,
"hitAt": 0.60,
"npcSpeed": 0.90
},
"Headbutt_clean.glb": {
"reactions": [
"NPC_receive_punch_to_the_face_clean.glb",
"NPC_taking_punch_clean.glb"
],
"npcRange": 0.70,
"hitAt": 0.55,
"npcSpeed": 1.30
}
}
Feature 9 — Safe Range (No Clipping)
The minimum range for the slider is computed from the two models’ bounding boxes, using depth (Z) — the axis they face along — not width:
js
/* In loadNpc(), after both characters are loaded: */
if (playerSize && npcSize){
const pr = playerSize.z / 2;
const nr = npcSize.z / 2;
/* 60% safety factor — bbox is measured at bind pose */
minSeparation = (pr + nr) * 0.6;
/* hard floor so the slider can't go absurdly low */
minSeparation = Math.max(minSeparation, 0.35);
console.log('[range] minSeparation =', minSeparation.toFixed(3));
}
Then the range slider binds its min to that:
js
const safeMin = Math.max(MIN_RANGE, minSeparation); rInput.min = safeMin; rInput.value = Math.max(range, safeMin);
What was tried and rejected: a dynamic hip-bone collision solver that pushed the NPC when the player lunged forward. It oscillated with the smooth-slide system and pushed the NPC back on attacks where the models didn’t visually overlap. Replaced with the static slider bound above — simpler, more predictable, no shake.
Full Source
The complete single-file implementation is one HTML file with four sections:
- CSS — sidebar, viewport, pairing editor, sliders, export modal
- HTML skeleton — the DOM structure
- importmap — three.js from unpkg
- Script — everything above, plus the scene setup, loaders, and render loop
The full file is ~1200 lines. Key extras beyond the features above:
- Three.js scene with
RoomEnvironmentfor IBL lighting GLTFLoader+DRACOLoaderfor compressed meshesAnimationMixerper character with fade-based blendingOrbitControlsfor cameraanimate()render loop driving both mixers and the NPC’s smooth slide- Distance guide (a dashed line from player to NPC) shown while dragging the range slider
The critical wiring between them is minimal:
js
/* Smooth NPC slide */
if (npcRoot){
const diff = npcTargetZ - npcCurrentZ;
if (Math.abs(diff) > 0.0005){
npcCurrentZ += diff * Math.min(1, dt * 10);
npcRoot.position.z = npcCurrentZ;
} else if (npcCurrentZ !== npcTargetZ){
npcCurrentZ = npcTargetZ;
npcRoot.position.z = npcTargetZ;
}
}
That single lerp is what makes all per-attack distance changes feel animated instead of snappy.
Integration with Game Runtime
The tool’s output is a plain JSON file. In your game:
js
const pairings = await fetch('fight_pairings.json').then(r => r.json());
function onPlayerAttack(attackFile, attackClip){
const entry = pairings[attackFile];
if (!entry) return;
/* 1. Position the NPC at the tuned distance */
npc.position.z = entry.npcRange;
/* 2. Schedule the reaction at the tuned hit frame */
const hitTimeMs = attackClip.duration * entry.hitAt * 1000;
setTimeout(() => {
/* 3. Pick a random reaction from the pool */
const reactionFile = entry.reactions[
Math.floor(Math.random() * entry.reactions.length)
];
const reactionClip = reactionClipMap[reactionFile];
if (!reactionClip) return;
/* 4. Play it at the tuned speed */
const action = npcMixer.clipAction(reactionClip);
action.setEffectiveTimeScale(entry.npcSpeed);
action.setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;
action.play();
/* 5. Fade back to idle when it ends */
const dur = reactionClip.duration * 1000 / entry.npcSpeed;
setTimeout(() => {
action.fadeOut(0.2);
npcIdleAction.reset().setEffectiveTimeScale(1).fadeIn(0.2).play();
}, dur);
}, hitTimeMs);
}
The game doesn’t need to know about sliders, previews, or localStorage. It just reads numbers and applies them.
Key Takeaways
1. The viewport is sacred. Never cover it. Bottom pane > modal.
2. Auto-preview on every slider release. Sub-100 ms feedback loops turn a config tool into a toy.
3. Bump storage schemas with migration functions. Never break saved data. Ship v4, support v1 through v4.
4. Don’t over-solve for correctness. A static minimum on a slider beats a dynamic collision solver — simpler, more predictable, designer-friendly.
5. Measure the right axis. size.x for side-by-side, size.z for face-to-face. Bounding boxes at bind pose lie.
6. JSON is the contract. The tool and the game should communicate through data, not code.
7. Ship with fallbacks. Server behavior is a variable you don’t control. A hardcoded file list costs 20 lines and saves hours.
The tool exists to answer one question per attack: “When the player does X, what does the NPC do, where, and how fast?” Everything else is scaffolding.
Tags: three.js, game dev, animation, tooling, web, combat systems, JSON, localStorage.