diff --git a/proto/api.proto b/proto/api.proto index 14420eb6d1..e0c34a7a93 100644 --- a/proto/api.proto +++ b/proto/api.proto @@ -295,6 +295,7 @@ message ComputeStatsRequest { } message AuraStats { ActionID id = 1; + int32 max_stacks = 2; } message SpellStats { ActionID id = 1; diff --git a/proto/apl.proto b/proto/apl.proto index 90e395efe2..e0e127317f 100644 --- a/proto/apl.proto +++ b/proto/apl.proto @@ -28,6 +28,7 @@ message APLAction { APLActionStrictSequence strict_sequence = 6; APLActionCastSpell cast_spell = 3; + APLActionMultidot multidot = 8; APLActionAutocastOtherCooldowns autocast_other_cooldowns = 7; APLActionWait wait = 4; } @@ -55,6 +56,20 @@ message APLValue { APLValueCurrentEnergy current_energy = 15; APLValueCurrentComboPoints current_combo_points = 16; + // GCD values + APLValueGCDIsReady gcd_is_ready = 17; + APLValueGCDTimeToReady gcd_time_to_ready = 18; + + // Spell values + APLValueSpellCanCast spell_can_cast = 19; + APLValueSpellIsReady spell_is_ready = 20; + APLValueSpellTimeToReady spell_time_to_ready = 21; + + // Aura values + APLValueAuraIsActive aura_is_active = 22; + APLValueAuraRemainingTime aura_remaining_time = 23; + APLValueAuraNumStacks aura_num_stacks = 24; + // Dot values APLValueDotIsActive dot_is_active = 6; APLValueDotRemainingTime dot_remaining_time = 13; @@ -88,6 +103,12 @@ message APLActionCastSpell { ActionID spell_id = 1; } +message APLActionMultidot { + ActionID spell_id = 1; + int32 max_dots = 2; + APLValue max_overlap = 3; +} + message APLActionAutocastOtherCooldowns { } @@ -148,9 +169,32 @@ message APLValueCurrentRage {} message APLValueCurrentEnergy {} message APLValueCurrentComboPoints {} +message APLValueGCDIsReady {} +message APLValueGCDTimeToReady {} + +message APLValueSpellCanCast { + ActionID spell_id = 1; +} +message APLValueSpellIsReady { + ActionID spell_id = 1; +} +message APLValueSpellTimeToReady { + ActionID spell_id = 1; +} + +message APLValueAuraIsActive { + ActionID aura_id = 1; +} +message APLValueAuraRemainingTime { + ActionID aura_id = 1; +} +message APLValueAuraNumStacks { + ActionID aura_id = 1; +} + message APLValueDotIsActive { ActionID spell_id = 1; } message APLValueDotRemainingTime { ActionID spell_id = 1; -} \ No newline at end of file +} diff --git a/sim/core/apl_action.go b/sim/core/apl_action.go index a47f7e9627..f5f28963f2 100644 --- a/sim/core/apl_action.go +++ b/sim/core/apl_action.go @@ -66,6 +66,8 @@ func (unit *Unit) newAPLActionImpl(config *proto.APLAction) APLActionImpl { return unit.newActionStrictSequence(config.GetStrictSequence()) case *proto.APLAction_CastSpell: return unit.newActionCastSpell(config.GetCastSpell()) + case *proto.APLAction_Multidot: + return unit.newActionMultidot(config.GetMultidot()) case *proto.APLAction_AutocastOtherCooldowns: return unit.newActionAutocastOtherCooldowns(config.GetAutocastOtherCooldowns()) case *proto.APLAction_Wait: diff --git a/sim/core/apl_actions_core.go b/sim/core/apl_actions_core.go index d7f6541a54..cd271ee530 100644 --- a/sim/core/apl_actions_core.go +++ b/sim/core/apl_actions_core.go @@ -28,6 +28,49 @@ func (action *APLActionCastSpell) Execute(sim *Simulation) { action.spell.Cast(sim, action.spell.Unit.CurrentTarget) } +type APLActionMultidot struct { + spell *Spell + maxDots int32 + maxOverlap APLValue + + nextTarget *Unit +} + +func (unit *Unit) newActionMultidot(config *proto.APLActionMultidot) APLActionImpl { + spell := unit.aplGetMultidotSpell(config.SpellId) + maxOverlap := unit.coerceTo(unit.newAPLValue(config.MaxOverlap), proto.APLValueType_ValueTypeDuration) + if maxOverlap == nil { + maxOverlap = unit.newValueConst(&proto.APLValueConst{Val: "0ms"}) + } + + return &APLActionMultidot{ + spell: spell, + maxDots: MinInt32(config.MaxDots, unit.Env.GetNumTargets()), + maxOverlap: maxOverlap, + } +} +func (action *APLActionMultidot) GetInnerActions() []*APLAction { return nil } +func (action *APLActionMultidot) Finalize() {} +func (action *APLActionMultidot) Reset(*Simulation) { + action.nextTarget = nil +} +func (action *APLActionMultidot) IsReady(sim *Simulation) bool { + maxOverlap := action.maxOverlap.GetDuration(sim) + + for i := int32(0); i < action.maxDots; i++ { + target := sim.Encounter.TargetUnits[i] + dot := action.spell.Dot(target) + if (!dot.IsActive() || dot.RemainingDuration(sim) < maxOverlap) && action.spell.CanCast(sim, target) { + action.nextTarget = target + return true + } + } + return false +} +func (action *APLActionMultidot) Execute(sim *Simulation) { + action.spell.Cast(sim, action.nextTarget) +} + type APLActionAutocastOtherCooldowns struct { character *Character diff --git a/sim/core/apl_value.go b/sim/core/apl_value.go index fa3af31cd6..6e1bea09be 100644 --- a/sim/core/apl_value.go +++ b/sim/core/apl_value.go @@ -79,6 +79,28 @@ func (unit *Unit) newAPLValue(config *proto.APLValue) APLValue { case *proto.APLValue_CurrentComboPoints: return unit.newValueCurrentComboPoints(config.GetCurrentComboPoints()) + // GCD + case *proto.APLValue_GcdIsReady: + return unit.newValueGCDIsReady(config.GetGcdIsReady()) + case *proto.APLValue_GcdTimeToReady: + return unit.newValueGCDTimeToReady(config.GetGcdTimeToReady()) + + // Spells + case *proto.APLValue_SpellCanCast: + return unit.newValueSpellCanCast(config.GetSpellCanCast()) + case *proto.APLValue_SpellIsReady: + return unit.newValueSpellIsReady(config.GetSpellIsReady()) + case *proto.APLValue_SpellTimeToReady: + return unit.newValueSpellTimeToReady(config.GetSpellTimeToReady()) + + // Auras + case *proto.APLValue_AuraIsActive: + return unit.newValueAuraIsActive(config.GetAuraIsActive()) + case *proto.APLValue_AuraRemainingTime: + return unit.newValueAuraRemainingTime(config.GetAuraRemainingTime()) + case *proto.APLValue_AuraNumStacks: + return unit.newValueAuraNumStacks(config.GetAuraNumStacks()) + // Dots case *proto.APLValue_DotIsActive: return unit.newValueDotIsActive(config.GetDotIsActive()) diff --git a/sim/core/apl_values_aura.go b/sim/core/apl_values_aura.go new file mode 100644 index 0000000000..54f2601ccd --- /dev/null +++ b/sim/core/apl_values_aura.go @@ -0,0 +1,77 @@ +package core + +import ( + "time" + + "github.com/wowsims/wotlk/sim/core/proto" +) + +func (unit *Unit) aplGetAura(auraId *proto.ActionID) *Aura { + return unit.GetAuraByID(ProtoToActionID(auraId)) +} + +type APLValueAuraIsActive struct { + defaultAPLValueImpl + aura *Aura +} + +func (unit *Unit) newValueAuraIsActive(config *proto.APLValueAuraIsActive) APLValue { + aura := unit.aplGetAura(config.AuraId) + if aura == nil { + return nil + } + return &APLValueAuraIsActive{ + aura: aura, + } +} +func (value *APLValueAuraIsActive) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeBool +} +func (value *APLValueAuraIsActive) GetBool(sim *Simulation) bool { + return value.aura.IsActive() +} + +type APLValueAuraRemainingTime struct { + defaultAPLValueImpl + aura *Aura +} + +func (unit *Unit) newValueAuraRemainingTime(config *proto.APLValueAuraRemainingTime) APLValue { + aura := unit.aplGetAura(config.AuraId) + if aura == nil { + return nil + } + return &APLValueAuraRemainingTime{ + aura: aura, + } +} +func (value *APLValueAuraRemainingTime) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeDuration +} +func (value *APLValueAuraRemainingTime) GetDuration(sim *Simulation) time.Duration { + return value.aura.RemainingDuration(sim) +} + +type APLValueAuraNumStacks struct { + defaultAPLValueImpl + aura *Aura +} + +func (unit *Unit) newValueAuraNumStacks(config *proto.APLValueAuraNumStacks) APLValue { + aura := unit.aplGetAura(config.AuraId) + if aura == nil { + return nil + } + if aura.MaxStacks == 0 { + return nil + } + return &APLValueAuraNumStacks{ + aura: aura, + } +} +func (value *APLValueAuraNumStacks) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeInt +} +func (value *APLValueAuraNumStacks) GetInt(sim *Simulation) int32 { + return value.aura.GetStacks() +} diff --git a/sim/core/apl_values_core.go b/sim/core/apl_values_core.go index b4cb8ddae0..949cdea38d 100644 --- a/sim/core/apl_values_core.go +++ b/sim/core/apl_values_core.go @@ -19,6 +19,14 @@ func (unit *Unit) aplGetDot(spellId *proto.ActionID) *Dot { } } +func (unit *Unit) aplGetMultidotSpell(spellId *proto.ActionID) *Spell { + spell := unit.GetSpell(ProtoToActionID(spellId)) + if spell == nil || spell.CurDot() == nil { + return nil + } + return spell +} + type APLValueDotIsActive struct { defaultAPLValueImpl dot *Dot diff --git a/sim/core/apl_values_gcd.go b/sim/core/apl_values_gcd.go new file mode 100644 index 0000000000..ec4a982465 --- /dev/null +++ b/sim/core/apl_values_gcd.go @@ -0,0 +1,41 @@ +package core + +import ( + "time" + + "github.com/wowsims/wotlk/sim/core/proto" +) + +type APLValueGCDIsReady struct { + defaultAPLValueImpl + unit *Unit +} + +func (unit *Unit) newValueGCDIsReady(config *proto.APLValueGCDIsReady) APLValue { + return &APLValueGCDIsReady{ + unit: unit, + } +} +func (value *APLValueGCDIsReady) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeBool +} +func (value *APLValueGCDIsReady) GetBool(sim *Simulation) bool { + return value.unit.GCD.IsReady(sim) +} + +type APLValueGCDTimeToReady struct { + defaultAPLValueImpl + unit *Unit +} + +func (unit *Unit) newValueGCDTimeToReady(config *proto.APLValueGCDTimeToReady) APLValue { + return &APLValueGCDTimeToReady{ + unit: unit, + } +} +func (value *APLValueGCDTimeToReady) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeDuration +} +func (value *APLValueGCDTimeToReady) GetDuration(sim *Simulation) time.Duration { + return value.unit.GCD.TimeToReady(sim) +} diff --git a/sim/core/apl_values_spell.go b/sim/core/apl_values_spell.go new file mode 100644 index 0000000000..850d6e8656 --- /dev/null +++ b/sim/core/apl_values_spell.go @@ -0,0 +1,74 @@ +package core + +import ( + "time" + + "github.com/wowsims/wotlk/sim/core/proto" +) + +func (unit *Unit) aplGetSpell(spellId *proto.ActionID) *Spell { + return unit.GetSpell(ProtoToActionID(spellId)) +} + +type APLValueSpellCanCast struct { + defaultAPLValueImpl + spell *Spell +} + +func (unit *Unit) newValueSpellCanCast(config *proto.APLValueSpellCanCast) APLValue { + spell := unit.aplGetSpell(config.SpellId) + if spell == nil { + return nil + } + return &APLValueSpellCanCast{ + spell: spell, + } +} +func (value *APLValueSpellCanCast) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeBool +} +func (value *APLValueSpellCanCast) GetBool(sim *Simulation) bool { + return value.spell.CanCast(sim, value.spell.Unit.CurrentTarget) +} + +type APLValueSpellIsReady struct { + defaultAPLValueImpl + spell *Spell +} + +func (unit *Unit) newValueSpellIsReady(config *proto.APLValueSpellIsReady) APLValue { + spell := unit.aplGetSpell(config.SpellId) + if spell == nil { + return nil + } + return &APLValueSpellIsReady{ + spell: spell, + } +} +func (value *APLValueSpellIsReady) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeBool +} +func (value *APLValueSpellIsReady) GetBool(sim *Simulation) bool { + return value.spell.IsReady(sim) +} + +type APLValueSpellTimeToReady struct { + defaultAPLValueImpl + spell *Spell +} + +func (unit *Unit) newValueSpellTimeToReady(config *proto.APLValueSpellTimeToReady) APLValue { + spell := unit.aplGetSpell(config.SpellId) + if spell == nil { + return nil + } + return &APLValueSpellTimeToReady{ + spell: spell, + } +} +func (value *APLValueSpellTimeToReady) Type() proto.APLValueType { + return proto.APLValueType_ValueTypeDuration +} +func (value *APLValueSpellTimeToReady) GetDuration(sim *Simulation) time.Duration { + return value.spell.TimeToReady(sim) +} diff --git a/sim/core/aura.go b/sim/core/aura.go index 8c55e46741..6e0816edf1 100644 --- a/sim/core/aura.go +++ b/sim/core/aura.go @@ -323,6 +323,14 @@ func (at *auraTracker) GetAura(label string) *Aura { } return nil } +func (at *auraTracker) GetAuraByID(actionID ActionID) *Aura { + for _, aura := range at.auras { + if aura.ActionID.SameAction(actionID) { + return aura + } + } + return nil +} func (at *auraTracker) HasAura(label string) bool { aura := at.GetAura(label) return aura != nil diff --git a/sim/core/character.go b/sim/core/character.go index 06fe69f7f7..6baa999bfa 100644 --- a/sim/core/character.go +++ b/sim/core/character.go @@ -518,6 +518,7 @@ func (character *Character) FillPlayerStats(playerStats *proto.PlayerStats) { playerStats.Auras = MapSlice(aplAuras, func(aura *Aura) *proto.AuraStats { return &proto.AuraStats{ Id: aura.ActionID.ToProto(), + MaxStacks: aura.MaxStacks, } }) } diff --git a/ui/core/components/individual_sim_ui/apl_actions.ts b/ui/core/components/individual_sim_ui/apl_actions.ts index f1bbf57395..6fd087c219 100644 --- a/ui/core/components/individual_sim_ui/apl_actions.ts +++ b/ui/core/components/individual_sim_ui/apl_actions.ts @@ -4,6 +4,7 @@ import { APLActionSequence, APLActionResetSequence, APLActionStrictSequence, + APLActionMultidot, APLActionAutocastOtherCooldowns, APLActionWait, APLValue, @@ -261,6 +262,32 @@ export const actionTypeFactories: Record, ActionTypeC actionListFieldConfig('actions'), ], }), + ['multidot']: inputBuilder({ + label: 'Multi Dot', + shortDescription: 'Keeps a DoT active on multiple targets by casting the specified spell.', + newValue: () => APLActionMultidot.create({ + maxDots: 3, + maxOverlap: { + value: { + oneofKind: 'const', + const: { + val: '0ms', + }, + }, + }, + }), + fields: [ + AplHelpers.actionIdFieldConfig('spellId', 'dot_spells'), + AplHelpers.numberFieldConfig('maxDots', { + label: 'Max Dots', + labelTooltip: 'Maximum number of DoTs to simultaneously apply.', + }), + AplValues.valueFieldConfig('maxOverlap', { + label: 'Overlap', + labelTooltip: 'Maximum amount of time before a DoT expires when it may be refreshed.', + }), + ], + }), ['autocastOtherCooldowns']: inputBuilder({ label: 'Autocast Other Cooldowns', submenu: ['Misc'], @@ -278,7 +305,16 @@ export const actionTypeFactories: Record, ActionTypeC label: 'Wait', submenu: ['Misc'], shortDescription: 'Pauses the GCD for a specified amount of time.', - newValue: APLActionWait.create, + newValue: () => APLActionWait.create({ + duration: { + value: { + oneofKind: 'const', + const: { + val: '1000ms', + }, + }, + }, + }), fields: [ AplValues.valueFieldConfig('duration'), ], diff --git a/ui/core/components/individual_sim_ui/apl_helpers.ts b/ui/core/components/individual_sim_ui/apl_helpers.ts index 10a9a98435..bbe869b968 100644 --- a/ui/core/components/individual_sim_ui/apl_helpers.ts +++ b/ui/core/components/individual_sim_ui/apl_helpers.ts @@ -3,16 +3,37 @@ import { Player } from '../../player.js'; import { EventID, TypedEvent } from '../../typed_event.js'; import { bucket } from '../../utils.js'; import { AdaptiveStringPicker } from '../string_picker.js'; +import { NumberPicker } from '../number_picker.js'; import { DropdownPicker, DropdownPickerConfig, DropdownValueConfig, TextDropdownPicker } from '../dropdown_picker.js'; import { Input, InputConfig } from '../input.js'; import { ActionID } from '../../proto/common.js'; -export type ACTION_ID_SET = 'castable_spells' | 'dot_spells'; +export type ACTION_ID_SET = 'auras' | 'stackable_auras' | 'castable_spells' | 'dot_spells'; const actionIdSets: Record) => Promise>>, }> = { + ['auras']: { + defaultLabel: 'Aura', + getActionIDs: async (player) => { + return player.getAuras().map(actionId => { + return { + value: actionId.id, + }; + }); + }, + }, + ['stackable_auras']: { + defaultLabel: 'Aura', + getActionIDs: async (player) => { + return player.getAuras().filter(aura => aura.data.maxStacks > 0).map(actionId => { + return { + value: actionId.id, + }; + }); + }, + }, ['castable_spells']: { defaultLabel: 'Spell', getActionIDs: async (player) => { @@ -46,9 +67,7 @@ const actionIdSets: Record { - const dotSpells = player.getSpells().filter(spell => spell.data.hasDot); - - return dotSpells.map(actionId => { + return player.getSpells().filter(spell => spell.data.hasDot).map(actionId => { return { value: actionId.id, }; @@ -100,6 +119,9 @@ export interface APLPickerBuilderFieldConfig { field: F, newValue: () => T[F], factory: (parent: HTMLElement, player: Player, config: InputConfig, T[F]>) => Input, T[F]> + + label?: string, + labelTooltip?: string, } export interface APLPickerBuilderConfig extends InputConfig, T> { @@ -137,6 +159,9 @@ export class APLPickerBuilder extends Input, T> { return { ...fieldConfig, picker: fieldConfig.factory(builder.rootElem, builder.modObject, { + label: fieldConfig.label, + labelTooltip: fieldConfig.labelTooltip, + inline: true, changedEvent: (player: Player) => player.rotationChangeEmitter, getValue: () => { const source = builder.getSourceValue(); @@ -172,7 +197,7 @@ export class APLPickerBuilder extends Input, T> { } } -export function actionIdFieldConfig(field: string, actionIdSet: ACTION_ID_SET): APLPickerBuilderFieldConfig { +export function actionIdFieldConfig(field: string, actionIdSet: ACTION_ID_SET, options?: Partial>): APLPickerBuilderFieldConfig { return { field: field, newValue: () => ActionID.create(), @@ -180,14 +205,25 @@ export function actionIdFieldConfig(field: string, actionIdSet: ACTION_ID_SET): ...config, actionIdSet: actionIdSet, }), + ...(options || {}), }; } -export function stringFieldConfig(field: string): APLPickerBuilderFieldConfig { +export function numberFieldConfig(field: string, options?: Partial>): APLPickerBuilderFieldConfig { + return { + field: field, + newValue: () => 0, + factory: (parent, player, config) => new NumberPicker(parent, player, config), + ...(options || {}), + }; +} + +export function stringFieldConfig(field: string, options?: Partial>): APLPickerBuilderFieldConfig { return { field: field, newValue: () => '', factory: (parent, player, config) => new AdaptiveStringPicker(parent, player, config), + ...(options || {}), }; } @@ -199,4 +235,4 @@ export function aplInputBuilder(newValue: () => T, fields: Array { +export function valueFieldConfig(field: string, options?: Partial>): AplHelpers.APLPickerBuilderFieldConfig { return { field: field, newValue: APLValue.create, factory: (parent, player, config) => new APLValuePicker(parent, player, config), + ...(options || {}), }; } @@ -361,6 +370,83 @@ const valueTypeFactories: Record, ValueTypeConfig fields: [], }), + // GCD + ['gcdIsReady']: inputBuilder({ + label: 'GCD Is Ready', + submenu: ['GCD'], + shortDescription: 'True if the GCD is not on cooldown, otherwise False.', + newValue: APLValueGCDIsReady.create, + fields: [], + }), + ['gcdTimeToReady']: inputBuilder({ + label: 'GCD Time To Ready', + submenu: ['GCD'], + shortDescription: 'Amount of time remaining before the GCD comes off cooldown, or 0 if it is not on cooldown.', + newValue: APLValueGCDTimeToReady.create, + fields: [], + }), + + // Spells + ['spellCanCast']: inputBuilder({ + label: 'Can Cast', + submenu: ['Spell'], + shortDescription: 'True if all requirements for casting the spell are currently met, otherwise False.', + fullDescription: ` +

The Cast Spell action does not need to be conditioned on this, because it applies this check automatically.

+ `, + newValue: APLValueSpellCanCast.create, + fields: [ + AplHelpers.actionIdFieldConfig('spellId', 'castable_spells'), + ], + }), + ['spellIsReady']: inputBuilder({ + label: 'Is Ready', + submenu: ['Spell'], + shortDescription: 'True if the spell is not on cooldown, otherwise False.', + newValue: APLValueSpellIsReady.create, + fields: [ + AplHelpers.actionIdFieldConfig('spellId', 'castable_spells'), + ], + }), + ['spellTimeToReady']: inputBuilder({ + label: 'Time To Ready', + submenu: ['Spell'], + shortDescription: 'Amount of time remaining before the spell comes off cooldown, or 0 if it is not on cooldown.', + newValue: APLValueSpellTimeToReady.create, + fields: [ + AplHelpers.actionIdFieldConfig('spellId', 'castable_spells'), + ], + }), + + // Auras + ['auraIsActive']: inputBuilder({ + label: 'Aura Is Active', + submenu: ['Aura'], + shortDescription: 'True if the aura is currently active on self, otherwise False.', + newValue: APLValueAuraIsActive.create, + fields: [ + AplHelpers.actionIdFieldConfig('auraId', 'auras'), + ], + }), + ['auraRemainingTime']: inputBuilder({ + label: 'Aura Remaining Time', + submenu: ['Aura'], + shortDescription: 'Time remaining before this aura will expire, or 0 if the aura is not currently active on self.', + newValue: APLValueAuraRemainingTime.create, + fields: [ + AplHelpers.actionIdFieldConfig('auraId', 'auras'), + ], + }), + ['auraNumStacks']: inputBuilder({ + label: 'Aura Num Stacks', + submenu: ['Aura'], + shortDescription: 'Number of stacks of the aura on self.', + newValue: APLValueAuraNumStacks.create, + fields: [ + AplHelpers.actionIdFieldConfig('auraId', 'stackable_auras'), + ], + }), + // DoT ['dotIsActive']: inputBuilder({ label: 'Dot Is Active', @@ -380,4 +466,4 @@ const valueTypeFactories: Record, ValueTypeConfig AplHelpers.actionIdFieldConfig('spellId', 'dot_spells'), ], }), -}; \ No newline at end of file +}; diff --git a/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss b/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss index aa9937911b..33f94db96f 100644 --- a/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss +++ b/ui/scss/core/components/individual_sim_ui/_apl_rotation_picker.scss @@ -60,13 +60,19 @@ flex-direction: column; } +.apl-picker-builder-root { + .form-label { + margin: 0 1px 0 5px; + } +} + .apl-value-picker-root { * { font-size: calc(var(--bs-btn-font-size) - 0.2rem); } .form-label { - margin: 0; + margin: 0 1px 0 5px; } }