39282-vm/assets/js/main.js
Flatlogic Bot 06b39604cf v7
2026-03-27 23:52:52 +00:00

523 lines
20 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
const appConfig = window.appConfig || {};
const toastElement = document.getElementById('app-toast');
const toastMessage = document.getElementById('toast-message');
const toast = toastElement && window.bootstrap ? new window.bootstrap.Toast(toastElement, { delay: 3200 }) : null;
const controls = {
angle: document.getElementById('angle_deg'),
backAngle: document.getElementById('back_angle'),
legAngle: document.getElementById('leg_angle'),
headAngle: document.getElementById('head_angle'),
intensity: document.getElementById('intensity_pct'),
pattern: document.getElementById('pattern_mode'),
name: document.getElementById('name'),
notes: document.getElementById('notes')
};
const ui = {
figure: document.getElementById('recliner-figure'),
motion: document.getElementById('recliner-motion'),
motionState: document.getElementById('motion-state'),
angleValue: document.getElementById('angle-value'),
anglePill: document.getElementById('angle-pill'),
backAnglePill: document.getElementById('back-angle-pill'),
legAnglePill: document.getElementById('leg-angle-pill'),
headAnglePill: document.getElementById('head-angle-pill'),
intensityPill: document.getElementById('intensity-pill'),
statAngle: document.getElementById('stat-angle'),
statIntensity: document.getElementById('stat-intensity'),
statPattern: document.getElementById('stat-pattern'),
saveAngle: document.getElementById('save-angle'),
saveIntensity: document.getElementById('save-intensity'),
savePattern: document.getElementById('save-pattern'),
summaryAngle: document.getElementById('summary-angle'),
summaryIntensity: document.getElementById('summary-intensity'),
summaryPattern: document.getElementById('summary-pattern'),
reclineMode: document.getElementById('recline-mode'),
gamepadState: document.getElementById('gamepad-state'),
gamepadSelect: document.getElementById('gamepad-select'),
testButton: document.getElementById('test-vibration'),
resetButton: document.getElementById('reset-defaults')
};
let knownGamepads = [];
let scanTimer = null;
let continuousVibrationInterval = null;
let activeActuator = null;
const notify = (message) => {
if (!message) {
return;
}
if (toastMessage) {
toastMessage.textContent = message;
}
if (toast) {
toast.show();
}
};
const presetTone = (angle) => {
if (angle >= 135) return 'Deep recline';
if (angle >= 90) return 'Lounge';
if (angle >= 45) return 'Reading';
return 'Upright';
};
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
const currentState = () => ({
angle: Number(controls.angle?.value || 0),
backAngle: Number(controls.backAngle?.value || 0),
legAngle: Number(controls.legAngle?.value || 0),
headAngle: Number(controls.headAngle?.value || 0),
intensity: Number(controls.intensity?.value || 0),
pattern: controls.pattern?.value || 'continuous'
});
const vibrationIsRunning = () => continuousVibrationInterval !== null;
const updateMotionIndicator = (isActive, pattern, intensity) => {
if (!ui.motionState) {
return;
}
if (!isActive) {
ui.motionState.textContent = 'Preview idle';
return;
}
ui.motionState.textContent = `${capitalize(pattern)} haptics · ${intensity}%`;
};
const syncFigureVariables = (state) => {
if (!ui.figure) {
return;
}
const driftX = -Math.round(state.angle * 0.22);
const driftY = Math.round(state.angle * 0.08);
const shakeX = (1.6 + state.intensity * 0.055).toFixed(2);
const shakeY = (0.9 + state.intensity * 0.028).toFixed(2);
const shakeRotate = (0.28 + state.intensity * 0.008).toFixed(2);
const vibeSpeed = Math.max(90, 220 - state.intensity).toFixed(0);
const glowStrength = Math.min(0.46, 0.14 + state.intensity / 320).toFixed(2);
const glowOpacity = Math.min(0.92, 0.36 + state.intensity / 180).toFixed(2);
ui.figure.style.setProperty('--angle', String(state.angle));
ui.figure.style.setProperty('--back-angle', String(state.backAngle));
ui.figure.style.setProperty('--leg-angle', String(state.legAngle));
ui.figure.style.setProperty('--head-angle', String(state.headAngle));
ui.figure.style.setProperty('--intensity', String(state.intensity));
ui.figure.style.setProperty('--drift-x', `${driftX}px`);
ui.figure.style.setProperty('--drift-y', `${driftY}px`);
ui.figure.style.setProperty('--shake-x', `${shakeX}px`);
ui.figure.style.setProperty('--shake-y', `${shakeY}px`);
ui.figure.style.setProperty('--shake-rotate', `${shakeRotate}deg`);
ui.figure.style.setProperty('--vibe-speed', `${vibeSpeed}ms`);
ui.figure.style.setProperty('--glow-strength', glowStrength);
ui.figure.style.setProperty('--glow-opacity', glowOpacity);
ui.figure.dataset.pattern = state.pattern;
};
const setVisualVibrationState = (isActive, state = currentState()) => {
syncFigureVariables(state);
if (ui.figure) {
ui.figure.classList.toggle('is-vibrating', isActive);
}
if (ui.motion) {
ui.motion.classList.toggle('is-vibrating', isActive);
ui.motion.classList.toggle('vibration-continuous', isActive && state.pattern === 'continuous');
ui.motion.classList.toggle('vibration-pulse', isActive && state.pattern === 'pulse');
}
updateMotionIndicator(isActive, state.pattern, state.intensity);
};
const updateVisualization = () => {
const state = currentState();
const tone = presetTone(state.angle);
syncFigureVariables(state);
const map = {
angleValue: `${state.angle}`,
anglePill: `${state.angle}°`,
backAnglePill: `${state.backAngle}°`,
legAnglePill: `${state.legAngle}°`,
headAnglePill: `${state.headAngle}°`,
intensityPill: `${state.intensity}%`,
statAngle: `${state.angle}°`,
statIntensity: `${state.intensity}%`,
statPattern: capitalize(state.pattern),
saveAngle: `${state.angle}°`,
saveIntensity: `${state.intensity}%`,
savePattern: capitalize(state.pattern),
summaryAngle: `${state.angle}°`,
summaryIntensity: `${state.intensity}%`,
summaryPattern: capitalize(state.pattern),
reclineMode: tone
};
Object.entries(map).forEach(([key, value]) => {
if (ui[key]) {
ui[key].textContent = value;
}
});
if (!vibrationIsRunning()) {
updateMotionIndicator(false, state.pattern, state.intensity);
} else {
setVisualVibrationState(true, state);
}
};
const applyPresetState = (preset) => {
if (!preset) return;
if (controls.angle && preset.angle_deg !== undefined) controls.angle.value = preset.angle_deg;
if (controls.backAngle && preset.back_angle !== undefined) controls.backAngle.value = preset.back_angle;
if (controls.legAngle && preset.leg_angle !== undefined) controls.legAngle.value = preset.leg_angle;
if (controls.headAngle && preset.head_angle !== undefined) controls.headAngle.value = preset.head_angle;
if (controls.intensity && preset.intensity_pct !== undefined) controls.intensity.value = preset.intensity_pct;
if (controls.pattern && preset.pattern_mode) controls.pattern.value = preset.pattern_mode;
if (controls.name && preset.name !== undefined) controls.name.value = preset.name;
if (controls.notes && preset.notes !== undefined) controls.notes.value = preset.notes;
updateVisualization();
};
const getActuator = (gamepad) => {
if (!gamepad) return null;
if (gamepad.vibrationActuator) return gamepad.vibrationActuator;
if (Array.isArray(gamepad.hapticActuators) && gamepad.hapticActuators.length > 0) {
return gamepad.hapticActuators[0];
}
return null;
};
const selectedGamepad = () => {
if (!ui.gamepadSelect || !knownGamepads.length) return null;
const selectedIndex = Number(ui.gamepadSelect.value || knownGamepads[0].index);
return knownGamepads.find((gamepad) => gamepad.index === selectedIndex) || knownGamepads[0];
};
const stopEffect = async (actuator) => {
if (!actuator) {
return;
}
try {
if (typeof actuator.reset === 'function') {
await actuator.reset();
return;
}
if (typeof actuator.playEffect === 'function') {
await actuator.playEffect('dual-rumble', {
startDelay: 0,
duration: 0,
weakMagnitude: 0,
strongMagnitude: 0
});
return;
}
if (typeof actuator.pulse === 'function') {
await actuator.pulse(0, 0);
}
} catch (error) {
console.debug('Unable to reset vibration actuator cleanly.', error);
}
};
const refreshGamepads = () => {
if (!navigator.getGamepads) {
if (ui.gamepadState) {
ui.gamepadState.textContent = 'Gamepad API unavailable';
}
if (ui.gamepadSelect) {
ui.gamepadSelect.innerHTML = '<option>Unsupported browser</option>';
ui.gamepadSelect.disabled = true;
}
if (ui.testButton) {
ui.testButton.disabled = true;
}
setVisualVibrationState(false);
return;
}
knownGamepads = Array.from(navigator.getGamepads()).filter(Boolean);
const previousValue = ui.gamepadSelect ? ui.gamepadSelect.value : '';
if (ui.gamepadSelect) {
ui.gamepadSelect.innerHTML = '';
if (!knownGamepads.length) {
const option = document.createElement('option');
option.textContent = 'No controller detected';
option.value = '';
ui.gamepadSelect.appendChild(option);
ui.gamepadSelect.disabled = true;
} else {
knownGamepads.forEach((gamepad) => {
const option = document.createElement('option');
const supported = getActuator(gamepad) ? 'haptics ready' : 'no haptics';
option.value = String(gamepad.index);
option.textContent = `${gamepad.id} (${supported})`;
ui.gamepadSelect.appendChild(option);
});
ui.gamepadSelect.disabled = false;
const hasPrevious = knownGamepads.some((gamepad) => String(gamepad.index) === previousValue);
ui.gamepadSelect.value = hasPrevious ? previousValue : String(knownGamepads[0].index);
}
}
const active = selectedGamepad();
const actuator = getActuator(active);
if (ui.gamepadState) {
if (!active) {
ui.gamepadState.textContent = 'Connect a controller to enable rumble';
} else if (!actuator) {
ui.gamepadState.textContent = 'Controller found, but haptics are unavailable';
} else {
ui.gamepadState.textContent = 'Controller connected and ready';
}
}
if (ui.testButton) {
ui.testButton.disabled = !active || !actuator;
}
if (vibrationIsRunning() && !actuator) {
window.clearInterval(continuousVibrationInterval);
continuousVibrationInterval = null;
activeActuator = null;
if (ui.testButton) {
ui.testButton.textContent = 'Test vibration (continuous)';
}
setVisualVibrationState(false);
}
};
const playEffect = async (actuator, intensity, duration) => {
if (typeof actuator.playEffect === 'function') {
await actuator.playEffect('dual-rumble', {
startDelay: 0,
duration,
weakMagnitude: Math.min(1, intensity * 0.8 + 0.1),
strongMagnitude: intensity
});
return;
}
if (typeof actuator.pulse === 'function') {
await actuator.pulse(intensity, duration);
}
};
const stopVibration = async ({ silent = false } = {}) => {
if (continuousVibrationInterval) {
window.clearInterval(continuousVibrationInterval);
continuousVibrationInterval = null;
}
await stopEffect(activeActuator);
activeActuator = null;
if (ui.testButton) {
ui.testButton.textContent = 'Test vibration (continuous)';
}
setVisualVibrationState(false);
if (!silent) {
notify('Vibration stopped.');
}
};
const startVibration = async (actuator, { silent = false } = {}) => {
const state = currentState();
const normalizedIntensity = Math.max(0, Math.min(1, state.intensity / 100));
activeActuator = actuator;
if (ui.testButton) {
ui.testButton.textContent = 'Stop vibration';
}
setVisualVibrationState(true, state);
if (state.pattern === 'pulse') {
const pulse = async () => {
const live = currentState();
setVisualVibrationState(true, live);
await playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 500);
};
await pulse();
continuousVibrationInterval = window.setInterval(() => {
pulse().catch((error) => console.error(error));
}, 800);
} else {
await playEffect(actuator, normalizedIntensity, 150);
continuousVibrationInterval = window.setInterval(() => {
const live = currentState();
setVisualVibrationState(true, live);
playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 150).catch((error) => console.error(error));
}, 100);
}
if (!silent) {
notify(`Running ${state.pattern} vibration at ${state.intensity}%.`);
}
};
const restartVibration = async () => {
if (!vibrationIsRunning()) {
return;
}
const gamepad = selectedGamepad();
const actuator = getActuator(gamepad);
if (!gamepad || !actuator) {
await stopVibration({ silent: true });
return;
}
await stopVibration({ silent: true });
await startVibration(actuator, { silent: true });
};
const runVibration = async () => {
const gamepad = selectedGamepad();
const actuator = getActuator(gamepad);
if (!gamepad || !actuator) {
notify('No compatible haptic controller is currently selected.');
refreshGamepads();
return;
}
if (vibrationIsRunning()) {
await stopVibration();
return;
}
try {
await startVibration(actuator);
} catch (error) {
console.error(error);
await stopVibration({ silent: true });
notify('The browser detected the controller, but vibration could not start.');
}
};
const wirePresetButtons = () => {
document.querySelectorAll('.apply-preset').forEach((button) => {
button.addEventListener('click', () => {
applyPresetState({
name: button.dataset.name || '',
angle_deg: button.dataset.angle || 0,
back_angle: button.dataset.back || 0,
leg_angle: button.dataset.leg || 0,
head_angle: button.dataset.head || 0,
intensity_pct: button.dataset.intensity || 0,
pattern_mode: button.dataset.pattern || 'continuous',
notes: button.dataset.notes || ''
});
window.location.hash = 'simulator';
notify(`Loaded preset “${button.dataset.name || 'Preset'}” into the simulator.`);
});
});
};
if (ui.testButton) {
ui.testButton.addEventListener('click', () => {
runVibration().catch((error) => console.error(error));
});
}
if (ui.resetButton) {
ui.resetButton.addEventListener('click', () => {
applyPresetState(appConfig.defaults || {});
notify('Demo defaults restored.');
});
}
[controls.angle, controls.backAngle, controls.legAngle, controls.headAngle, controls.intensity].forEach((element) => {
if (element) {
element.addEventListener('input', updateVisualization);
element.addEventListener('change', updateVisualization);
}
});
if (controls.pattern) {
controls.pattern.addEventListener('input', () => {
updateVisualization();
restartVibration().catch((error) => console.error(error));
});
controls.pattern.addEventListener('change', () => {
updateVisualization();
restartVibration().catch((error) => console.error(error));
});
}
if (ui.gamepadSelect) {
ui.gamepadSelect.addEventListener('change', refreshGamepads);
}
window.addEventListener('gamepadconnected', (event) => {
refreshGamepads();
notify(`Connected: ${event.gamepad.id}`);
});
window.addEventListener('gamepaddisconnected', (event) => {
refreshGamepads();
notify(`Disconnected: ${event.gamepad.id}`);
});
// Keyboard Shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
const step = e.shiftKey ? 10 : 5;
switch (e.key.toLowerCase()) {
case 'v':
runVibration().catch(console.error);
break;
case 'r':
if (ui.resetButton) ui.resetButton.click();
break;
case 'arrowup':
if (controls.angle) {
controls.angle.value = Math.min(180, Number(controls.angle.value) + step);
updateVisualization();
}
break;
case 'arrowdown':
if (controls.angle) {
controls.angle.value = Math.max(0, Number(controls.angle.value) - step);
updateVisualization();
}
break;
case 'arrowright':
if (controls.intensity) {
controls.intensity.value = Math.min(100, Number(controls.intensity.value) + step);
updateVisualization();
}
break;
case 'arrowleft':
if (controls.intensity) {
controls.intensity.value = Math.max(0, Number(controls.intensity.value) - step);
updateVisualization();
}
break;
}
});
applyPresetState(appConfig.initialPreset || {});
wirePresetButtons();
refreshGamepads();
updateVisualization();
if (appConfig.toastMessage) {
notify(appConfig.toastMessage);
}
if (navigator.getGamepads) {
scanTimer = window.setInterval(refreshGamepads, 1500);
}
window.addEventListener('beforeunload', () => {
if (scanTimer) {
window.clearInterval(scanTimer);
}
if (continuousVibrationInterval) {
window.clearInterval(continuousVibrationInterval);
}
stopEffect(activeActuator).catch(() => {});
});
});