From 3df25a2879174a39ad156b51419aa37d9edcf22a Mon Sep 17 00:00:00 2001 From: Jonatas Dias Date: Mon, 11 May 2026 08:31:12 -0300 Subject: [PATCH] Add keyboard fire effect --- src/main/animation/animationfire.js | 103 +++++++++++++++++++++++++ src/main/device/razerdevicekeyboard.js | 19 ++++- src/main/feature/featurefire.js | 15 ++++ src/main/feature/featurehelper.js | 5 +- src/main/feature/featureidentifier.js | 3 +- src/main/menu/menubuilderdevice.js | 49 ++++++++++++ src/main/statemanager.js | 5 +- 7 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 src/main/animation/animationfire.js create mode 100644 src/main/feature/featurefire.js diff --git a/src/main/animation/animationfire.js b/src/main/animation/animationfire.js new file mode 100644 index 00000000..c408177a --- /dev/null +++ b/src/main/animation/animationfire.js @@ -0,0 +1,103 @@ +import { RazerDeviceAnimation } from './animation'; + +export class RazerAnimationFire extends RazerDeviceAnimation { + constructor(device, featureConfiguration, colors = []) { + super(); + this.fireEffectInterval = null; + + this.device = device; + this.nRows = featureConfiguration.rows; + this.nCols = featureConfiguration.cols; + this.heat = Array(this.nRows) + .fill() + .map(() => Array(this.nCols).fill(0)); + + const primaryColor = colors.length >= 3 ? colors.slice(0, 3) : [0xff, 0x45, 0]; + this.primaryColor = this.normalizeColor(primaryColor, [0xff, 0x45, 0]); + + const secondaryColor = colors.length >= 6 + ? colors.slice(3, 6) + : this.blendColor(this.primaryColor, [0xff, 0xff, 0xff], 0.35); + this.secondaryColor = this.normalizeColor(secondaryColor, [0xff, 0xb0, 0]); + } + + start() { + const refreshRate = 70; // milliseconds + + this.fireEffectInterval = setInterval(() => { + this.heat = this.nextHeatFrame(); + const matrix = this.heat.map(row => row.map(heat => this.colorForHeat(heat))); + + for (let i = 0; i < this.nRows; i++) { + let row = [i, 0, this.nCols - 1, ...matrix[i].flat()]; + this.device.setCustomFrame(new Uint8Array(row)); + } + this.device.setModeCustom(); + }, refreshRate); + } + + stop() { + clearInterval(this.fireEffectInterval); + } + + destroy() { + this.stop(); + } + + nextHeatFrame() { + return this.heat.map((row, rowIndex) => { + return row.map((heat, colIndex) => { + if (rowIndex === this.nRows - 1) { + return this.clamp01((heat * 0.35) + (Math.random() * 0.75) + 0.15); + } + + const sourceRow = Math.min(this.nRows - 1, rowIndex + 1); + const sourceCol = this.clamp(colIndex + this.randomInt(-1, 1), 0, this.nCols - 1); + const belowHeat = this.heat[sourceRow][sourceCol]; + const sameHeat = this.heat[rowIndex][colIndex]; + const cooling = (Math.random() * 0.16) + (rowIndex / this.nRows * 0.06); + + return this.clamp01((belowHeat * 0.82) + (sameHeat * 0.08) - cooling); + }); + }); + } + + colorForHeat(heat) { + if (heat < 0.08) { + return [0, 0, 0]; + } + if (heat < 0.55) { + return this.blendColor([0, 0, 0], this.primaryColor, heat / 0.55); + } + if (heat < 0.9) { + return this.blendColor(this.primaryColor, this.secondaryColor, (heat - 0.55) / 0.35); + } + return this.blendColor(this.secondaryColor, [0xff, 0xf0, 0xc0], (heat - 0.9) / 0.1); + } + + blendColor(from, to, ratio) { + const clampedRatio = this.clamp01(ratio); + return from.map((channel, index) => { + return Math.round(channel + ((to[index] - channel) * clampedRatio)); + }); + } + + normalizeColor(color, fallback) { + if (color == null || color.length < 3) { + return fallback; + } + return color.slice(0, 3).map(channel => this.clamp(Math.round(channel), 0, 255)); + } + + randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + clamp01(value) { + return this.clamp(value, 0, 1); + } + + clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } +} diff --git a/src/main/device/razerdevicekeyboard.js b/src/main/device/razerdevicekeyboard.js index 70f1d106..312ea843 100644 --- a/src/main/device/razerdevicekeyboard.js +++ b/src/main/device/razerdevicekeyboard.js @@ -1,4 +1,5 @@ import { RazerDevice } from './razerdevice'; +import { RazerAnimationFire } from '../animation/animationfire'; import { RazerAnimationRipple } from '../animation/animationripple'; import { RazerAnimationWheel } from '../animation/animationwheel'; @@ -7,6 +8,7 @@ export class RazerDeviceKeyboard extends RazerDevice { super(addon, settingsManager, stateManager, razerProperties); this.rippleAnimation = null; this.wheelAnimation = null; + this.fireAnimation = null; } async init() { @@ -22,7 +24,7 @@ export class RazerDeviceKeyboard extends RazerDevice { } getSerializeIgnoredProperties() { - return super.getSerializeIgnoredProperties().concat(['rippleAnimation', 'wheelAnimation']); + return super.getSerializeIgnoredProperties().concat(['rippleAnimation', 'wheelAnimation', 'fireAnimation']); } getState() { @@ -44,6 +46,9 @@ export class RazerDeviceKeyboard extends RazerDevice { if(this.wheelAnimation != null) { this.wheelAnimation.destroy(); } + if(this.fireAnimation != null) { + this.fireAnimation.destroy(); + } } setModeNone() { @@ -108,6 +113,9 @@ export class RazerDeviceKeyboard extends RazerDevice { if(this.wheelAnimation != null) { this.wheelAnimation.stop(); } + if(this.fireAnimation != null) { + this.fireAnimation.stop(); + } } setRippleEffect(featureConfiguration, color, backgroundColor) { @@ -124,10 +132,17 @@ export class RazerDeviceKeyboard extends RazerDevice { this.wheelAnimation.start(); } + setFireEffect(featureConfiguration, colors) { + this.setModeState('fire', [featureConfiguration, colors]); + this.stopAnimations(); + this.fireAnimation = new RazerAnimationFire(this, featureConfiguration, colors); + this.fireAnimation.start(); + } + setCustomFrame(frame) { this.addon.kbdSetCustomFrame(this.internalId, new Uint8Array(frame)); } setModeCustom() { this.addon.kbdSetModeCustom(this.internalId); } -} \ No newline at end of file +} diff --git a/src/main/feature/featurefire.js b/src/main/feature/featurefire.js new file mode 100644 index 00000000..943e7246 --- /dev/null +++ b/src/main/feature/featurefire.js @@ -0,0 +1,15 @@ +import { Feature } from './feature'; +import { FeatureIdentifier } from './featureidentifier'; + +export class FeatureFire extends Feature { + constructor(config) { + super(FeatureIdentifier.FIRE, config); + } + + getDefaultConfiguration() { + return { + rows: -1, + cols: -1, + }; + } +} diff --git a/src/main/feature/featurehelper.js b/src/main/feature/featurehelper.js index 05cb71d2..efef360f 100644 --- a/src/main/feature/featurehelper.js +++ b/src/main/feature/featurehelper.js @@ -7,6 +7,7 @@ import { FeatureBreathe } from './featurebreathe'; import { FeatureStarlight } from './featurestarlight'; import { FeatureRipple } from './featureripple'; import { FeatureWheel } from './featurewheel'; +import { FeatureFire } from './featurefire'; import { FeatureBrightness } from './featurebrightness'; import { FeatureWaveSimple } from './featurewavesimple'; import { FeatureOldMouseEffects } from './featureoldmouseeffects'; @@ -34,6 +35,7 @@ export class FeatureHelper { case FeatureIdentifier.BRIGHTNESS: return new FeatureBrightness(configuration); case FeatureIdentifier.RIPPLE: return new FeatureRipple(configuration); case FeatureIdentifier.WHEEL: return new FeatureWheel(configuration); + case FeatureIdentifier.FIRE: return new FeatureFire(configuration); case FeatureIdentifier.OLD_MOUSE_EFFECTS: return new FeatureOldMouseEffects(configuration); case FeatureIdentifier.MOUSE_BRIGHTNESS: return new FeatureMouseBrightness(configuration); case FeatureIdentifier.POLL_RATE: return new FeatureMousePollRate(configuration); @@ -57,6 +59,7 @@ export class FeatureHelper { new FeatureStarlight(), new FeatureRipple(), new FeatureWheel(), + new FeatureFire(), new FeatureBrightness(), ]; case RazerDeviceType.MOUSE: @@ -118,4 +121,4 @@ export class FeatureHelper { return []; } } -} \ No newline at end of file +} diff --git a/src/main/feature/featureidentifier.js b/src/main/feature/featureidentifier.js index ba8c9301..3be0b01c 100644 --- a/src/main/feature/featureidentifier.js +++ b/src/main/feature/featureidentifier.js @@ -11,8 +11,9 @@ FeatureIdentifier.STARLIGHT = 'starlight'; FeatureIdentifier.BRIGHTNESS = 'brightness'; FeatureIdentifier.RIPPLE = 'ripple'; FeatureIdentifier.WHEEL = 'wheel'; +FeatureIdentifier.FIRE = 'fire'; FeatureIdentifier.OLD_MOUSE_EFFECTS = 'oldMouseEffects'; FeatureIdentifier.MOUSE_BRIGHTNESS = 'mouseBrightness'; FeatureIdentifier.POLL_RATE = 'pollRate'; FeatureIdentifier.MOUSE_DPI = 'dpi'; -FeatureIdentifier.BATTERY = 'battery'; \ No newline at end of file +FeatureIdentifier.BATTERY = 'battery'; diff --git a/src/main/menu/menubuilderdevice.js b/src/main/menu/menubuilderdevice.js index a5af038f..9dcf3bf6 100644 --- a/src/main/menu/menubuilderdevice.js +++ b/src/main/menu/menubuilderdevice.js @@ -77,6 +77,8 @@ function getFeatureMenuFor(application, device, feature) { return getFeatureRipple(application, device, feature); case FeatureIdentifier.WHEEL: return getFeatureWheel(application, device, feature); + case FeatureIdentifier.FIRE: + return getFeatureFire(application, device, feature); case FeatureIdentifier.OLD_MOUSE_EFFECTS: return getFeatureOldMouseEffect(application, device, feature); case FeatureIdentifier.MOUSE_BRIGHTNESS: @@ -258,6 +260,53 @@ function getFeatureWheel(application, device, feature) { }; } +function getFeatureFire(application, device, feature) { + const configuration = getMatrixConfiguration(device, feature); + + if(configuration == null) { + return { + // device missing rows, cols config + label: 'Fire', + enabled: false + }; + } + + const singleItem = (label, colors) => { + return { + label: label, + click() { + device.setFireEffect(configuration, colors); + }, + }; + }; + + return { + label: 'Fire', + submenu: [ + singleItem('Custom color', Object.values(device.settings.customColor1.rgb).slice(0, 3)), + singleItem('Custom dual color', + Object.values(device.settings.customColor1.rgb).slice(0, 3) + .concat(Object.values(device.settings.customColor2.rgb).slice(0, 3)), + ), + singleItem('Classic', [0xff, 0x24, 0, 0xff, 0xb0, 0]), + singleItem('Blue', [0, 0x55, 0xff, 0xbf, 0xff, 0xff]), + singleItem('Green', [0, 0xff, 0x40, 0xff, 0xff, 0x80]), + ], + }; +} + +function getMatrixConfiguration(device, feature) { + const configurations = [ + feature.configuration, + device.hasFeature(FeatureIdentifier.WHEEL) ? device.getFeature(FeatureIdentifier.WHEEL).configuration : null, + device.hasFeature(FeatureIdentifier.RIPPLE) ? device.getFeature(FeatureIdentifier.RIPPLE).configuration : null, + ]; + + return configurations.find(configuration => { + return configuration != null && configuration.rows !== -1 && configuration.cols !== -1; + }); +} + function getFeatureSpectrum(application, device, feature) { return { label: 'Spectrum', diff --git a/src/main/statemanager.js b/src/main/statemanager.js index d683801e..b591a591 100644 --- a/src/main/statemanager.js +++ b/src/main/statemanager.js @@ -202,6 +202,9 @@ export class StateManager { case 'wheel': device.setWheelEffect(state.args[0], state.args[1]); break; + case 'fire': + device.setFireEffect(state.args[0], state.args[1]); + break; default: console.error('Unknown State mode ' + state.mode); } @@ -223,4 +226,4 @@ export class StateManager { stateOnUserDidResignActive: this.stateOnUserDidResignActive, }; } -} \ No newline at end of file +}