477 lines
18 KiB
JavaScript
477 lines
18 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.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,
|
|
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}`);
|
|
});
|
|
|
|
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(() => {});
|
|
});
|
|
}); |