From 7d140919f583797de4f445c205cbb1cf6a46444f Mon Sep 17 00:00:00 2001 From: extio-de Date: Fri, 3 Apr 2026 13:32:54 +0200 Subject: [PATCH] Added OptionsModule --- README.md | 22 +- .../game_engine/menu/AbstractOptionsTab.java | 105 +++ .../menu/HsbColorPickerModule.java | 314 +++++++ .../menu/MenuAutoConfiguration.java | 67 ++ .../menu/OpenOptionsModuleEvent.java | 7 + .../game_engine/menu/OptionsAudioTab.java | 208 +++++ .../game_engine/menu/OptionsKeyboardTab.java | 95 +++ .../extio/game_engine/menu/OptionsModule.java | 280 +++++++ .../menu/OptionsModuleContext.java | 19 + .../de/extio/game_engine/menu/OptionsTab.java | 26 + .../menu/OptionsThemeEditorSavedEvent.java | 7 + .../menu/OptionsThemeEditorSupport.java | 11 + .../game_engine/menu/OptionsThemesTab.java | 387 +++++++++ .../game_engine/menu/OptionsVideoTab.java | 287 +++++++ .../game_engine/menu/ThemeEditorModule.java | 774 ++++++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + 16 files changed, 2607 insertions(+), 3 deletions(-) create mode 100644 src/main/java/de/extio/game_engine/menu/AbstractOptionsTab.java create mode 100644 src/main/java/de/extio/game_engine/menu/HsbColorPickerModule.java create mode 100644 src/main/java/de/extio/game_engine/menu/MenuAutoConfiguration.java create mode 100644 src/main/java/de/extio/game_engine/menu/OpenOptionsModuleEvent.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsAudioTab.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsKeyboardTab.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsModule.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsModuleContext.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsTab.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSavedEvent.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSupport.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsThemesTab.java create mode 100644 src/main/java/de/extio/game_engine/menu/OptionsVideoTab.java create mode 100644 src/main/java/de/extio/game_engine/menu/ThemeEditorModule.java diff --git a/README.md b/README.md index 10906e1..9b4190b 100644 --- a/README.md +++ b/README.md @@ -1140,13 +1140,29 @@ None (utility classes) ### Menu System #### Purpose and Overview -Framework for building in-game menus and UI screens as modules. Provides base components for menu rendering and interaction handling. +Framework for building in-game menus and UI screens as modules. Provides reusable menu infrastructure, including a generic options dialog with auto-discovered tabs, a theme editor, and an HSB color picker dialog. #### Setup / Autoconfiguration -Integrated with the module system. Menu components are implemented as `AbstractClientModule` instances. +**Configuration Class**: `MenuAutoConfiguration` + +**Properties**: +- `game-engine.menu.enabled` (default: `true`) +- `game-engine.menu.options.enabled` (default: `true`) +- `game-engine.menu.theme-editor.enabled` (default: `true`) +- `game-engine.menu.hsb-color-picker.enabled` (default: `true`) + +The menu subsystem is auto-configured through explicit bean definitions, matching the rest of the engine. The options dialog auto-discovers all `OptionsTab` beans, so applications can contribute additional tabs from their own codebase without modifying the library. The theme editor implements `OptionsThemeEditorSupport`, and the reusable themes tab will use it when present. + +`ThemeEditorModule` depends on `HsbColorPickerModule`, so disabling `game-engine.menu.hsb-color-picker.enabled` also prevents theme editor registration. #### Exposed Spring Beans -None (menu implementations are modules) +- `OptionsModule` when `game-engine.menu.options.enabled=true` +- `OptionsAudioTab`, `OptionsVideoTab`, `OptionsKeyboardTab`, and `OptionsThemesTab` when `game-engine.menu.options.enabled=true` +- `ThemeEditorModule` when `game-engine.menu.theme-editor.enabled=true` +- `HsbColorPickerModule` when `game-engine.menu.hsb-color-picker.enabled=true` + +#### Integration Notes +All menu implementations are still regular `AbstractClientModule` modules at runtime. The auto-configuration only controls bean registration. --- diff --git a/src/main/java/de/extio/game_engine/menu/AbstractOptionsTab.java b/src/main/java/de/extio/game_engine/menu/AbstractOptionsTab.java new file mode 100644 index 0000000..9c2077a --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/AbstractOptionsTab.java @@ -0,0 +1,105 @@ +package de.extio.game_engine.menu; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import de.extio.game_engine.audio.AudioControl; +import de.extio.game_engine.event.EventService; +import de.extio.game_engine.i18n.LocalizationService; +import de.extio.game_engine.keyboard.KeycodeRegistry; +import de.extio.game_engine.renderer.RendererControl; +import de.extio.game_engine.renderer.ThemeManager; +import de.extio.game_engine.renderer.container.ScrollArea; +import de.extio.game_engine.renderer.container.Window; +import de.extio.game_engine.renderer.work.RenderingBoPool; +import de.extio.game_engine.storage.StorageService; + +public abstract class AbstractOptionsTab { + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private RenderingBoPool renderingBoPool; + + @Autowired + private EventService eventService; + + @Autowired + private LocalizationService localizationService; + + @Autowired + private RendererControl rendererControl; + + @Autowired + private AudioControl audioControl; + + @Autowired + private ThemeManager themeManager; + + @Autowired + private StorageService storageService; + + @Autowired + private KeycodeRegistry keycodeRegistry; + + protected final ApplicationContext applicationContext() { + return this.applicationContext; + } + + protected final RenderingBoPool renderingBoPool() { + return this.renderingBoPool; + } + + protected final EventService eventService() { + return this.eventService; + } + + protected final LocalizationService localizationService() { + return this.localizationService; + } + + protected final RendererControl rendererControl() { + return this.rendererControl; + } + + protected final AudioControl audioControl() { + return this.audioControl; + } + + protected final ThemeManager themeManager() { + return this.themeManager; + } + + protected final StorageService storageService() { + return this.storageService; + } + + protected final KeycodeRegistry keycodeRegistry() { + return this.keycodeRegistry; + } + + protected final Window optionsWindow(final OptionsModuleContext context) { + return context.optionsWindow(); + } + + protected final ScrollArea contentScrollArea(final OptionsModuleContext context) { + return context.contentScrollArea(); + } + + protected final void refreshContent(final OptionsModuleContext context) { + context.refreshContent(); + } + + protected final void rebuildWindow(final OptionsModuleContext context) { + context.rebuildWindow(); + } + + protected final int contentWidth(final OptionsModuleContext context) { + return context.contentWidth(); + } + + protected static String sanitizeId(final String input) { + return input.replaceAll("[^a-zA-Z0-9]", "_"); + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/HsbColorPickerModule.java b/src/main/java/de/extio/game_engine/menu/HsbColorPickerModule.java new file mode 100644 index 0000000..cbc5e97 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/HsbColorPickerModule.java @@ -0,0 +1,314 @@ +package de.extio.game_engine.menu; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import de.extio.game_engine.event.Event; +import de.extio.game_engine.event.EventService; +import de.extio.game_engine.module.AbstractClientModule; +import de.extio.game_engine.renderer.RendererControl; +import de.extio.game_engine.renderer.container.Window; +import de.extio.game_engine.renderer.model.RenderingBoLayer; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SliderControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SliderData; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.color.HSBColor; +import de.extio.game_engine.renderer.model.color.ImmutableRgbaColor; +import de.extio.game_engine.renderer.model.event.UiControlEvent; +import de.extio.game_engine.renderer.work.RenderingBoPool; +import de.extio.game_engine.spatial2.model.ImmutableCoordI2; + +public class HsbColorPickerModule extends AbstractClientModule { + + public record HsbColorPickerResponseEvent(String requestId, boolean confirmed, HSBColor color) implements Event { + } + + private static final int WINDOW_WIDTH = 700; + + private static final int WINDOW_HEIGHT = 520; + + private static final int LABEL_HEIGHT = 34; + + private static final int SLIDER_HEIGHT = 34; + + private static final int BUTTON_HEIGHT = 40; + + private static final int BUTTON_WIDTH = 110; + + private static final int SPACING = 12; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private RenderingBoPool renderingBoPool; + + @Autowired + private EventService eventService; + + private Window dialogWindow; + + private String requestId; + + private String title; + + private float hue; + + private float saturation; + + private float brightness; + + @Override + public void onLoad() { + this.dialogWindow = this.applicationContext.getBean(Window.class); + this.dialogWindow.setNormalizedDimension(ImmutableCoordI2.create(WINDOW_WIDTH, WINDOW_HEIGHT)); + this.dialogWindow.setNormalizedPosition(this.centeredPosition(this.dialogWindow.getNormalizedDimension())); + this.dialogWindow.setDraggable(true); + this.dialogWindow.setCloseButton(true); + this.dialogWindow.setOnCloseAction(this::onCancel); + } + + @Override + public void onUnload() { + this.getModuleService().unloadModule(this.dialogWindow.getId()); + } + + @Override + public void onActivate() { + this.setupDialog(); + this.getModuleService().changeActiveState(this.dialogWindow.getId(), true); + this.eventService.register(UiControlEvent.class, this.getId(), this::onUiControlEvent); + } + + @Override + public void onDeactivate() { + this.eventService.unregisterAll(this.getId()); + this.getModuleService().changeActiveState(this.dialogWindow.getId(), false); + } + + @Override + public void onShow() { + this.getModuleService().changeDisplayState(this.dialogWindow.getId(), true); + } + + @Override + public void onHide() { + this.getModuleService().changeDisplayState(this.dialogWindow.getId(), false); + } + + public void open(final String requestId, final String title, final HSBColor initialColor, final Window parentWindow) { + this.requestId = requestId; + this.title = title; + if (initialColor != null) { + this.hue = initialColor.getHue(); + this.saturation = initialColor.getSaturation(); + this.brightness = initialColor.getBrightness(); + } + else { + this.hue = 0.0f; + this.saturation = 0.0f; + this.brightness = 0.0f; + } + this.dialogWindow.setParent(parentWindow); + this.getModuleService().changeActiveState(this.getId(), true); + this.getModuleService().changeDisplayState(this.getId(), true); + } + + private void onUiControlEvent(final UiControlEvent event) { + if (event.getId() == null) { + return; + } + + switch (event.getId()) { + case "HsbColorPicker_Button_Ok" -> this.onOk(); + case "HsbColorPicker_Button_Cancel" -> this.onCancel(); + case "HsbColorPicker_Slider_Hue" -> this.updateSliderValue(event, SliderTarget.HUE); + case "HsbColorPicker_Slider_Saturation" -> this.updateSliderValue(event, SliderTarget.SATURATION); + case "HsbColorPicker_Slider_Brightness" -> this.updateSliderValue(event, SliderTarget.BRIGHTNESS); + } + } + + private void updateSliderValue(final UiControlEvent event, final SliderTarget target) { + if (!(event.getPayload() instanceof final Double rawValue)) { + return; + } + final float value = Math.max(0.0f, Math.min(1.0f, rawValue.floatValue())); + switch (target) { + case HUE -> this.hue = value; + case SATURATION -> this.saturation = value; + case BRIGHTNESS -> this.brightness = value; + } + this.refreshPreview(); + } + + private void setupDialog() { + this.dialogWindow.clearRenderingBos(); + + final var contentWidth = WINDOW_WIDTH - Window.MARGIN_LEFT - Window.MARGIN_RIGHT; + var yOffset = Window.MARGIN_TOP; + + if (this.title != null && !this.title.isBlank()) { + final var titleBo = this.renderingBoPool.acquire("HsbColorPicker_Title", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.title) + .setFontSize(28) + .setControlData(new LabelData(null, null, HorizontalAlignment.CENTER)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(titleBo); + yOffset += LABEL_HEIGHT + SPACING; + } + + final var preview = this.renderingBoPool.acquire("HsbColorPicker_Preview", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(" ") + .setFontSize(20) + .setControlData(new LabelData(this.toRgbaColor(this.getCurrentColor()), null, HorizontalAlignment.CENTER)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, 80) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(preview); + + yOffset += 80 + SPACING * 2; + + yOffset = this.addSliderRow("Hue", "HsbColorPicker_Slider_Hue", "HsbColorPicker_Value_Hue", this.hue, yOffset, contentWidth); + yOffset = this.addSliderRow("Saturation", "HsbColorPicker_Slider_Saturation", "HsbColorPicker_Value_Saturation", this.saturation, yOffset, contentWidth); + yOffset = this.addSliderRow("Brightness", "HsbColorPicker_Slider_Brightness", "HsbColorPicker_Value_Brightness", this.brightness, yOffset, contentWidth); + + yOffset += SPACING * 2; + + final var buttonY = yOffset; + final var okButton = this.renderingBoPool.acquire("HsbColorPicker_Button_Ok", ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption("OK") + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT + contentWidth - (BUTTON_WIDTH * 2 + SPACING), buttonY) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(okButton); + + final var cancelButton = this.renderingBoPool.acquire("HsbColorPicker_Button_Cancel", ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption("Cancel") + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT + contentWidth - BUTTON_WIDTH, buttonY) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(cancelButton); + + this.dialogWindow.draw(); + } + + private int addSliderRow(final String label, final String sliderId, final String valueId, final float value, final int yOffset, final int contentWidth) { + final var labelWidth = 160; + final var valueWidth = 100; + final var sliderWidth = contentWidth - labelWidth - valueWidth - SPACING * 2; + + var bo = this.renderingBoPool.acquire("HsbColorPicker_Label_" + label, ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(label) + .setFontSize(24) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(labelWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(bo); + + bo = this.renderingBoPool.acquire(sliderId, ControlRenderingBo.class) + .setType(SliderControl.class) + .setControlData(new SliderData(true, value, value, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(sliderWidth, SLIDER_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT + labelWidth + SPACING, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(bo); + + bo = this.renderingBoPool.acquire(valueId, ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.formatValue(value)) + .setFontSize(22) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(valueWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT + labelWidth + SPACING + sliderWidth + SPACING, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(bo); + + return yOffset + LABEL_HEIGHT + SPACING; + } + + private void refreshPreview() { + final var preview = this.dialogWindow.getRenderingBo("HsbColorPicker_Preview", ControlRenderingBo.class); + if (preview != null) { + preview.setControlData(new LabelData(this.toRgbaColor(this.getCurrentColor()), null, HorizontalAlignment.CENTER)); + this.dialogWindow.putRenderingBo(preview); + } + this.updateValueLabel("HsbColorPicker_Value_Hue", this.hue); + this.updateValueLabel("HsbColorPicker_Value_Saturation", this.saturation); + this.updateValueLabel("HsbColorPicker_Value_Brightness", this.brightness); + this.dialogWindow.draw(); + } + + private void updateValueLabel(final String id, final float value) { + final var bo = this.dialogWindow.getRenderingBo(id, ControlRenderingBo.class); + if (bo != null) { + bo.setCaption(this.formatValue(value)); + this.dialogWindow.putRenderingBo(bo); + } + } + + private String formatValue(final float value) { + return String.format("%.2f", value); + } + + private HSBColor getCurrentColor() { + return new HSBColor(this.hue, this.saturation, this.brightness); + } + + private ImmutableRgbaColor toRgbaColor(final HSBColor color) { + return new ImmutableRgbaColor(color.toColor()); + } + + private void onOk() { + this.eventService.fire(new HsbColorPickerResponseEvent(this.requestId, true, this.getCurrentColor())); + this.close(); + } + + private void onCancel() { + this.eventService.fire(new HsbColorPickerResponseEvent(this.requestId, false, null)); + this.close(); + } + + private void close() { + this.getModuleService().changeActiveState(this.getId(), false); + this.getModuleService().changeDisplayState(this.getId(), false); + } + + private ImmutableCoordI2 centeredPosition(final de.extio.game_engine.spatial2.model.CoordI2 dimension) { + final var referenceResolution = RendererControl.REFERENCE_RESOLUTION; + final var x = (referenceResolution.getX() - dimension.getX()) / 2; + final var y = (referenceResolution.getY() - dimension.getY()) / 2; + return ImmutableCoordI2.create(x, y); + } + + private enum SliderTarget { + HUE, + SATURATION, + BRIGHTNESS + } +} diff --git a/src/main/java/de/extio/game_engine/menu/MenuAutoConfiguration.java b/src/main/java/de/extio/game_engine/menu/MenuAutoConfiguration.java new file mode 100644 index 0000000..da03af5 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/MenuAutoConfiguration.java @@ -0,0 +1,67 @@ +package de.extio.game_engine.menu; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; + +import de.extio.game_engine.event.EventService; +import de.extio.game_engine.i18n.LocalizationService; +import de.extio.game_engine.renderer.work.RenderingBoPool; + +@AutoConfiguration +@ConditionalOnProperty(name = "game-engine.menu.enabled", havingValue = "true", matchIfMissing = true) +public class MenuAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.menu.options.enabled", havingValue = "true", matchIfMissing = true) + OptionsModule optionsModule(final ApplicationContext applicationContext, final RenderingBoPool renderingBoPool, final EventService eventService, + final LocalizationService localizationService) { + return new OptionsModule(applicationContext, renderingBoPool, eventService, localizationService); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.menu.options.enabled", havingValue = "true", matchIfMissing = true) + OptionsAudioTab optionsAudioTab() { + return new OptionsAudioTab(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.menu.options.enabled", havingValue = "true", matchIfMissing = true) + OptionsVideoTab optionsVideoTab() { + return new OptionsVideoTab(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.menu.options.enabled", havingValue = "true", matchIfMissing = true) + OptionsKeyboardTab optionsKeyboardTab() { + return new OptionsKeyboardTab(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.menu.options.enabled", havingValue = "true", matchIfMissing = true) + OptionsThemesTab optionsThemesTab() { + return new OptionsThemesTab(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.menu.hsb-color-picker.enabled", havingValue = "true", matchIfMissing = true) + HsbColorPickerModule hsbColorPickerModule() { + return new HsbColorPickerModule(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "game-engine.menu.theme-editor.enabled", havingValue = "true", matchIfMissing = true) + @ConditionalOnProperty(name = "game-engine.menu.hsb-color-picker.enabled", havingValue = "true", matchIfMissing = true) + ThemeEditorModule themeEditorModule() { + return new ThemeEditorModule(); + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OpenOptionsModuleEvent.java b/src/main/java/de/extio/game_engine/menu/OpenOptionsModuleEvent.java new file mode 100644 index 0000000..df952ed --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OpenOptionsModuleEvent.java @@ -0,0 +1,7 @@ +package de.extio.game_engine.menu; + +import de.extio.game_engine.event.Event; + +public record OpenOptionsModuleEvent(String tab) implements Event { + +} diff --git a/src/main/java/de/extio/game_engine/menu/OptionsAudioTab.java b/src/main/java/de/extio/game_engine/menu/OptionsAudioTab.java new file mode 100644 index 0000000..87b733e --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsAudioTab.java @@ -0,0 +1,208 @@ +package de.extio.game_engine.menu; + +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SliderControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SliderData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SwitchControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SwitchData; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.event.UiControlEvent; + +final class OptionsAudioTab extends AbstractOptionsTab implements OptionsTab { + + static final String TAB_ID = "audio"; + private static final String CONTROL_ID = "OptionsModule_Tab_Audio"; + + @Override + public String tabId() { + return TAB_ID; + } + + @Override + public String controlId() { + return CONTROL_ID; + } + + @Override + public String navigationLocalizationKey() { + return "ecyoa-85"; + } + + @Override + public String fallbackCaption() { + return "Audio"; + } + + @Override + public int navigationOrder() { + return 40; + } + + @Override + public void render(final OptionsModuleContext context) { + var yPos = 10; + + var bo = this.renderingBoPool().acquire("OptionsModule_Audio_Label_SFX", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-97", "Sound Effects:")) + .setFontSize(48) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 60; + + final var sfxEnabled = this.audioControl().getAudioOptions().getSfxOptions().isEnable(); + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_SFX_Enable", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-98", "Enable")) + .setFontSize(42) + .setType(SwitchControl.class) + .setControlData(new SwitchData(sfxEnabled, false, null, true)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(620, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 80; + + final var sfxVolume = this.audioControl().getAudioOptions().getSfxOptions().getVolume(); + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_Label_SFX_Volume", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-99", "Volume:")) + .setFontSize(42) + .setType(LabelControl.class) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(200, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_SFX_Volume_Slider", ControlRenderingBo.class) + .setCaption(String.format("%.2f", sfxVolume)) + .setFontSize(42) + .setType(SliderControl.class) + .setControlData(new SliderData(true, sfxVolume, 0.0, null)) + .setVisible(true) + .setEnabled(sfxEnabled) + .withDimensionAbsolute(400, 60) + .withPositionRelative(220, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 70; + + final var sfxSoftwareMixing = this.audioControl().getAudioOptions().getSfxOptions().isSoftwareMixing(); + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_SFX_SoftwareMixing", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-100", "Software Mixing")) + .setFontSize(42) + .setType(SwitchControl.class) + .setControlData(new SwitchData(sfxSoftwareMixing, false, null, true)) + .setVisible(true) + .setEnabled(sfxEnabled) + .withDimensionAbsolute(620, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 110; + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_Label_Music", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-101", "Music:")) + .setFontSize(48) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 60; + + final var musicEnabled = this.audioControl().getAudioOptions().getMusicOptions().isEnable(); + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_Music_Enable", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-98", "Enable")) + .setFontSize(42) + .setType(SwitchControl.class) + .setControlData(new SwitchData(musicEnabled, false, null, true)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(620, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 80; + + final var musicVolume = this.audioControl().getAudioOptions().getMusicOptions().getVolume(); + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_Label_Music_Volume", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-99", "Volume:")) + .setFontSize(42) + .setType(LabelControl.class) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(200, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + bo = this.renderingBoPool().acquire("OptionsModule_Audio_Music_Volume_Slider", ControlRenderingBo.class) + .setCaption(String.format("%.2f", musicVolume)) + .setFontSize(42) + .setType(SliderControl.class) + .setControlData(new SliderData(true, musicVolume, 0.0, null)) + .setVisible(true) + .setEnabled(musicEnabled) + .withDimensionAbsolute(400, 60) + .withPositionRelative(220, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + } + + @Override + public boolean handle(final UiControlEvent event, final OptionsModuleContext context) { + final var controlId = event.getId(); + if (controlId == null) { + return false; + } + + switch (controlId) { + case "OptionsModule_Audio_SFX_Enable" -> { + final var currentValue = this.audioControl().getAudioOptions().getSfxOptions().isEnable(); + this.audioControl().getAudioOptions().getSfxOptions().setEnable(!currentValue); + this.audioControl().applyAudioOptions(this.audioControl().getAudioOptions()); + this.refreshContent(context); + return true; + } + case "OptionsModule_Audio_Music_Enable" -> { + final var currentValue = this.audioControl().getAudioOptions().getMusicOptions().isEnable(); + this.audioControl().getAudioOptions().getMusicOptions().setEnable(!currentValue); + this.audioControl().applyAudioOptions(this.audioControl().getAudioOptions()); + this.refreshContent(context); + return true; + } + case "OptionsModule_Audio_SFX_SoftwareMixing" -> { + final var currentValue = this.audioControl().getAudioOptions().getSfxOptions().isSoftwareMixing(); + this.audioControl().getAudioOptions().getSfxOptions().setSoftwareMixing(!currentValue); + this.audioControl().applyAudioOptions(this.audioControl().getAudioOptions()); + this.refreshContent(context); + return true; + } + case "OptionsModule_Audio_Music_SoftwareMixing" -> { + final var currentValue = this.audioControl().getAudioOptions().getMusicOptions().isSoftwareMixing(); + this.audioControl().getAudioOptions().getMusicOptions().setSoftwareMixing(!currentValue); + this.audioControl().applyAudioOptions(this.audioControl().getAudioOptions()); + this.refreshContent(context); + return true; + } + default -> { + return false; + } + } + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsKeyboardTab.java b/src/main/java/de/extio/game_engine/menu/OptionsKeyboardTab.java new file mode 100644 index 0000000..4b6d9c1 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsKeyboardTab.java @@ -0,0 +1,95 @@ +package de.extio.game_engine.menu; + +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.TextfieldControl; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.event.UiControlEvent; + +final class OptionsKeyboardTab extends AbstractOptionsTab implements OptionsTab { + + static final String TAB_ID = "keyboard"; + private static final String CONTROL_ID = "OptionsModule_Tab_Keyboard"; + + @Override + public String tabId() { + return TAB_ID; + } + + @Override + public String controlId() { + return CONTROL_ID; + } + + @Override + public String navigationLocalizationKey() { + return "ecyoa-86"; + } + + @Override + public String fallbackCaption() { + return "Keyboard"; + } + + @Override + public int navigationOrder() { + return 50; + } + + @Override + public boolean visibleInNavigation() { + return false; + } + + @Override + public void render(final OptionsModuleContext context) { + final var keycodeRegistrations = this.keycodeRegistry().getAll(); + + var yPos = 10; + + var bo = this.renderingBoPool().acquire("OptionsModule_Keyboard_Label_Header", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-102", "Keyboard Shortcuts:")) + .setFontSize(42) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 80) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 90; + + for (final var registration : keycodeRegistrations) { + final var controlId = "OptionsModule_Keyboard_" + sanitizeId(registration.getQualifier()); + + bo = this.renderingBoPool().acquire(controlId + "_Label", ControlRenderingBo.class) + .setCaption(registration.getQualifier()) + .setFontSize(42) + .setType(LabelControl.class) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(400, 60) + .withPositionRelative(10, yPos + 10); + this.contentScrollArea(context).putRenderingBo(bo); + + bo = this.renderingBoPool().acquire(controlId + "_Key", ControlRenderingBo.class) + .setCaption(registration.getKeyCodeDisplay() != null ? registration.getKeyCodeDisplay() : String.valueOf((char) registration.getCode())) + .setFontSize(42) + .setType(TextfieldControl.class) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(400, 60) + .withPositionRelative(420, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 70; + } + } + + @Override + public boolean handle(final UiControlEvent event, final OptionsModuleContext context) { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsModule.java b/src/main/java/de/extio/game_engine/menu/OptionsModule.java new file mode 100644 index 0000000..747ea1d --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsModule.java @@ -0,0 +1,280 @@ +package de.extio.game_engine.menu; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import de.extio.game_engine.event.EventService; +import de.extio.game_engine.i18n.LocalizationService; +import de.extio.game_engine.module.AbstractClientModule; +import de.extio.game_engine.renderer.RendererControl; +import de.extio.game_engine.renderer.container.ScrollArea; +import de.extio.game_engine.renderer.container.Window; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonData; +import de.extio.game_engine.renderer.model.event.MouseClickEvent; +import de.extio.game_engine.renderer.model.event.UiControlEvent; +import de.extio.game_engine.renderer.work.RenderingBoPool; +import de.extio.game_engine.spatial2.model.CoordI2; +import de.extio.game_engine.spatial2.model.ImmutableCoordI2; + +public class OptionsModule extends AbstractClientModule implements OptionsModuleContext { + + private static final String TAB_GROUP = "OptionsModule_TabGroup"; + private static final int TAB_BUTTON_HEIGHT = 60; + private static final int TAB_BUTTON_WIDTH = 220; + private static final int CONTENT_START_Y = 140; + + private final ApplicationContext applicationContext; + + private final RenderingBoPool renderingBoPool; + + private final EventService eventService; + + private final LocalizationService localizationService; + + @Autowired + private List discoveredTabs; + + OptionsModule(final ApplicationContext applicationContext, final RenderingBoPool renderingBoPool, final EventService eventService, + final LocalizationService localizationService) { + this.applicationContext = applicationContext; + this.renderingBoPool = renderingBoPool; + this.eventService = eventService; + this.localizationService = localizationService; + } + + private List tabs = List.of(); + private Map tabsById = Map.of(); + private Map tabsByControlId = Map.of(); + + private Window optionsWindow; + private ScrollArea contentScrollArea; + private String activeTabId; + + @Override + public void onLoad() { + this.initializeTabs(); + this.optionsWindow = this.applicationContext.getBean(Window.class); + this.optionsWindow.setNormalizedDimension(RendererControl.REFERENCE_RESOLUTION.divide(9).multiply(7)); + this.optionsWindow.setNormalizedPosition(centeredPosition(this.optionsWindow.getNormalizedDimension())); + this.optionsWindow.setDraggable(false); + this.optionsWindow.setCloseButton(true); + this.optionsWindow.setOnCloseAction(this::closeOptions); + + this.contentScrollArea = this.applicationContext.getBean(ScrollArea.class); + this.optionsWindow.addComponent(this.contentScrollArea); + } + + @Override + public void onUnload() { + this.getModuleService().unloadModule(this.optionsWindow.getId()); + } + + @Override + public void onActivate() { + this.setupOptionsWindow(); + this.getModuleService().changeActiveState(this.optionsWindow.getId(), true); + this.getModuleService().hideExcept(this.getId(), this.optionsWindow.getId()); + this.eventService.register(UiControlEvent.class, this.getId(), this::onUiControlEvent); + this.eventService.register(OptionsThemeEditorSavedEvent.class, this.getId(), this::onThemeEditorSaved); + this.eventService.register(MouseClickEvent.class, this.getId(), event -> { + if (event.isPressed() && (event.getButton() == 1 || event.getButton() == 3)) { + this.refreshContent(); + } + }); + this.eventService.register(OpenOptionsModuleEvent.class, this.getId(), event -> { + if (event.tab() != null && !event.tab().isBlank()) { + this.openTab(event.tab()); + } else { + this.getModuleService().changeActiveState(this.getId(), true); + this.getModuleService().changeDisplayState(this.getId(), true); + } + }); + } + + @Override + public void onDeactivate() { + this.eventService.unregisterAll(this.getId()); + this.getModuleService().changeActiveState(this.optionsWindow.getId(), false); + this.getModuleService().restoreVisibility(); + } + + @Override + public void onShow() { + this.getModuleService().changeDisplayState(this.optionsWindow.getId(), true); + } + + @Override + public void onHide() { + this.getModuleService().changeDisplayState(this.optionsWindow.getId(), false); + } + + public void openTab(final String tabId) { + this.initializeTabs(); + if (this.tabsById.containsKey(tabId)) { + this.activeTabId = tabId; + } + this.getModuleService().changeActiveState(this.getId(), true); + this.getModuleService().changeDisplayState(this.getId(), true); + } + + @Override + public void rebuildWindow() { + this.setupOptionsWindow(); + } + + @Override + public void refreshContent() { + for (final var boId : this.contentScrollArea.getRenderingBoIds().toArray(new String[0])) { + this.contentScrollArea.removeRenderingBo(boId); + } + for (final var tab : this.tabs) { + tab.onContentCleared(this); + } + this.activeTab().render(this); + this.optionsWindow.draw(); + } + + @Override + public Window optionsWindow() { + return this.optionsWindow; + } + + @Override + public ScrollArea contentScrollArea() { + return this.contentScrollArea; + } + + private void setupOptionsWindow() { + final var windowWidth = this.optionsWindow.getNormalizedDimension().getX(); + + final var tabsY = Window.MARGIN_TOP; + final var tabSpacing = 10; + var tabX = Window.MARGIN_LEFT; + + for (final var tab : this.tabs) { + if (!tab.visibleInNavigation()) { + continue; + } + final var bo = this.renderingBoPool.acquire(tab.controlId(), ControlRenderingBo.class) + .setCaption(this.localizationService.translate(tab.navigationLocalizationKey(), tab.fallbackCaption())) + .setFontSize(36) + .setType(ToggleButtonControl.class) + .setControlGroup(TAB_GROUP) + .setControlData(new ToggleButtonData(tab.tabId().equals(this.activeTabId), false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(TAB_BUTTON_WIDTH, TAB_BUTTON_HEIGHT) + .withPositionRelative(tabX, tabsY); + this.optionsWindow.putRenderingBo(bo); + tabX += TAB_BUTTON_WIDTH + tabSpacing; + } + + final var bottomButtonsY = this.optionsWindow.getNormalizedDimension().getY() - Window.MARGIN_BOTTOM - 60; + final var buttonWidth = 200; + final var buttonHeight = 60; + + final var closeButton = this.renderingBoPool.acquire("OptionsModule_Button_Close", ControlRenderingBo.class) + .setCaption(this.localizationService.translate("ecyoa-67", "Cancel")) + .setFontSize(36) + .setType(ButtonControl.class) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(buttonWidth, buttonHeight) + .withPositionRelative(windowWidth - Window.MARGIN_RIGHT - buttonWidth, bottomButtonsY); + this.optionsWindow.putRenderingBo(closeButton); + + this.contentScrollArea.setRelativePosition(ImmutableCoordI2.create(Window.MARGIN_LEFT, CONTENT_START_Y)); + this.contentScrollArea.setDimension(ImmutableCoordI2.create( + windowWidth - Window.MARGIN_LEFT - Window.MARGIN_RIGHT, + bottomButtonsY - CONTENT_START_Y - 20)); + + this.refreshContent(); + this.optionsWindow.draw(); + } + + private void initializeTabs() { + if (!this.tabs.isEmpty()) { + return; + } + + final var orderedTabs = new ArrayList<>(this.discoveredTabs); + orderedTabs.sort(Comparator.comparingInt(OptionsTab::navigationOrder)); + this.tabs = List.copyOf(orderedTabs); + + final var tabsById = new LinkedHashMap(); + final var tabsByControlId = new LinkedHashMap(); + for (final var tab : this.tabs) { + if (tabsById.put(tab.tabId(), tab) != null) { + throw new IllegalStateException("Duplicate options tab id: " + tab.tabId()); + } + if (tabsByControlId.put(tab.controlId(), tab) != null) { + throw new IllegalStateException("Duplicate options tab control id: " + tab.controlId()); + } + } + this.tabsById = Map.copyOf(tabsById); + this.tabsByControlId = Map.copyOf(tabsByControlId); + + if (this.activeTabId == null && !this.tabs.isEmpty()) { + this.activeTabId = this.tabs.stream() + .filter(OptionsTab::visibleInNavigation) + .findFirst() + .orElse(this.tabs.getFirst()) + .tabId(); + } + } + + private OptionsTab activeTab() { + this.initializeTabs(); + final var activeTab = this.tabsById.get(this.activeTabId); + if (activeTab != null) { + return activeTab; + } + return this.tabs.getFirst(); + } + + private void onUiControlEvent(final UiControlEvent event) { + final var controlId = event.getId(); + if (controlId == null) { + return; + } + + final var selectedTab = this.tabsByControlId.get(controlId); + if (selectedTab != null) { + this.activeTabId = selectedTab.tabId(); + this.setupOptionsWindow(); + return; + } + + switch (controlId) { + case "OptionsModule_Button_Close" -> this.closeOptions(); + default -> this.activeTab().handle(event, this); + } + } + + private void onThemeEditorSaved(final OptionsThemeEditorSavedEvent event) { + if (event.saved()) { + this.refreshContent(); + } + } + + private void closeOptions() { + this.getModuleService().changeActiveState(this.getId(), false); + this.getModuleService().changeDisplayState(this.getId(), false); + } + + private static ImmutableCoordI2 centeredPosition(final CoordI2 dimension) { + final var referenceResolution = RendererControl.REFERENCE_RESOLUTION; + final var x = Math.max(0, (referenceResolution.getX() - dimension.getX()) / 2); + final var y = Math.max(0, (referenceResolution.getY() - dimension.getY()) / 2); + return ImmutableCoordI2.create(x, y); + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsModuleContext.java b/src/main/java/de/extio/game_engine/menu/OptionsModuleContext.java new file mode 100644 index 0000000..37f0040 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsModuleContext.java @@ -0,0 +1,19 @@ +package de.extio.game_engine.menu; + +import de.extio.game_engine.renderer.container.ScrollArea; +import de.extio.game_engine.renderer.container.Window; + +public interface OptionsModuleContext { + + Window optionsWindow(); + + ScrollArea contentScrollArea(); + + void refreshContent(); + + void rebuildWindow(); + + default int contentWidth() { + return this.optionsWindow().getNormalizedDimension().getX() - Window.MARGIN_LEFT - Window.MARGIN_RIGHT; + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsTab.java b/src/main/java/de/extio/game_engine/menu/OptionsTab.java new file mode 100644 index 0000000..c3e13a6 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsTab.java @@ -0,0 +1,26 @@ +package de.extio.game_engine.menu; + +import de.extio.game_engine.renderer.model.event.UiControlEvent; + +public interface OptionsTab { + String tabId(); + + String controlId(); + + String navigationLocalizationKey(); + + String fallbackCaption(); + + int navigationOrder(); + + default boolean visibleInNavigation() { + return true; + } + + void render(OptionsModuleContext context); + + boolean handle(UiControlEvent event, OptionsModuleContext context); + + default void onContentCleared(final OptionsModuleContext context) { + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSavedEvent.java b/src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSavedEvent.java new file mode 100644 index 0000000..47e7b4a --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSavedEvent.java @@ -0,0 +1,7 @@ +package de.extio.game_engine.menu; + +import de.extio.game_engine.event.Event; +import de.extio.game_engine.renderer.model.Theme; + +public record OptionsThemeEditorSavedEvent(boolean saved, Theme theme) implements Event { +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSupport.java b/src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSupport.java new file mode 100644 index 0000000..4c6280a --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsThemeEditorSupport.java @@ -0,0 +1,11 @@ +package de.extio.game_engine.menu; + +import de.extio.game_engine.renderer.container.Window; +import de.extio.game_engine.renderer.model.Theme; + +public interface OptionsThemeEditorSupport { + + void openNew(Theme baseTheme, String proposedName, Window parentWindow); + + void openEdit(Theme theme, Window parentWindow); +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsThemesTab.java b/src/main/java/de/extio/game_engine/menu/OptionsThemesTab.java new file mode 100644 index 0000000..7738428 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsThemesTab.java @@ -0,0 +1,387 @@ +package de.extio.game_engine.menu; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; + +import de.extio.game_engine.renderer.model.RenderingBoHasDimension; +import de.extio.game_engine.renderer.model.RenderingBoLayer; +import de.extio.game_engine.renderer.model.Theme; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuItem; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonData; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.event.UiControlEvent; + +final class OptionsThemesTab extends AbstractOptionsTab implements OptionsTab { + + static final String TAB_ID = "themes"; + private static final String CONTROL_ID = "OptionsModule_Tab_Themes"; + + private static final List CUSTOM_THEME_PATH = List.of("customThemes"); + private static final String CUSTOM_THEME_MENU_ID = "OptionsModule_CustomTheme_Menu"; + private static final String CUSTOM_THEME_MENU_SELECT = "OptionsModule_CustomThemeMenu_Select"; + private static final String CUSTOM_THEME_MENU_EDIT = "OptionsModule_CustomThemeMenu_Edit"; + private static final String CUSTOM_THEME_MENU_DELETE = "OptionsModule_CustomThemeMenu_Delete"; + + @Autowired + private ObjectProvider themeEditorSupportProvider; + + private final Map themeSelectionByControlId = new HashMap<>(); + private final Map customThemeSelectionByControlId = new HashMap<>(); + private String activeCustomThemeName; + + @Override + public String tabId() { + return TAB_ID; + } + + @Override + public String controlId() { + return CONTROL_ID; + } + + @Override + public String navigationLocalizationKey() { + return "ecyoa-87"; + } + + @Override + public String fallbackCaption() { + return "Themes"; + } + + @Override + public int navigationOrder() { + return 20; + } + + @Override + public void render(final OptionsModuleContext context) { + this.themeSelectionByControlId.clear(); + this.customThemeSelectionByControlId.clear(); + + final var themeEditorSupport = this.themeEditorSupportProvider.getIfAvailable(); + final var customThemeNames = this.getCustomThemeNames(); + final var themeNames = this.applicationContext().getBeansOfType(Theme.class).values().stream() + .map(Theme::getName) + .sorted() + .toList(); + final var currentTheme = this.themeManager().getCurrentTheme(); + final var currentThemeName = currentTheme != null ? currentTheme.getName() : null; + + var yPos = 10; + + var bo = this.renderingBoPool().acquire("OptionsModule_Themes_Label_Header", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-103", "Available Themes:")) + .setFontSize(42) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 80) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 90; + + final var columnWidth = 330; + final var columnSpacing = 20; + var xPos = 10; + var columnIndex = 0; + var emptyRow = false; + + for (final var themeName : themeNames) { + emptyRow = false; + final var controlId = "OptionsModule_Theme_" + sanitizeId(themeName); + this.themeSelectionByControlId.put(controlId, themeName); + + bo = this.renderingBoPool().acquire(controlId, ControlRenderingBo.class) + .setCaption(themeName) + .setFontSize(32) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_ThemeGroup") + .setControlData(new ToggleButtonData(themeName.equals(currentThemeName), true, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(columnWidth, 60) + .withPositionRelative(xPos, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + columnIndex++; + if (columnIndex >= 4) { + columnIndex = 0; + xPos = 10; + yPos += 70; + emptyRow = true; + } + else { + xPos += columnWidth + columnSpacing; + } + } + + yPos += emptyRow ? 20 : 90; + xPos = 10; + columnIndex = 0; + + bo = this.renderingBoPool().acquire("OptionsModule_Themes_Label_Custom", ControlRenderingBo.class) + .setCaption("Custom Themes") + .setFontSize(40) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(600, 70) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 90; + + for (final var themeName : customThemeNames) { + final var controlId = "OptionsModule_CustomTheme_" + sanitizeId(themeName); + this.customThemeSelectionByControlId.put(controlId, themeName); + + bo = this.renderingBoPool().acquire(controlId, ControlRenderingBo.class) + .setCaption(themeName) + .setFontSize(32) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_CustomThemeGroup") + .setControlData(new ToggleButtonData(themeName.equals(currentThemeName), true, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(columnWidth, 60) + .withPositionRelative(xPos, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + columnIndex++; + if (columnIndex >= 4) { + columnIndex = 0; + xPos = 10; + yPos += 70; + } + else { + xPos += columnWidth + columnSpacing; + } + } + + if (themeEditorSupport != null) { + final var addBo = this.renderingBoPool().acquire("OptionsModule_CustomTheme_Add", ControlRenderingBo.class) + .setCaption("+") + .setFontSize(36) + .setType(ButtonControl.class) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(columnWidth, 60) + .withPositionRelative(xPos, yPos); + this.contentScrollArea(context).putRenderingBo(addBo); + } + } + + @Override + public boolean handle(final UiControlEvent event, final OptionsModuleContext context) { + final var controlId = event.getId(); + if (controlId == null) { + return false; + } + + switch (controlId) { + case "OptionsModule_CustomTheme_Add" -> { + this.openNewThemeEditor(context); + return true; + } + case CUSTOM_THEME_MENU_ID -> { + this.handleCustomThemeMenuSelection(event.getPayload(), context); + return true; + } + default -> { + } + } + + final var customThemeName = this.customThemeSelectionByControlId.get(controlId); + if (customThemeName != null) { + final var bo = this.optionsWindow(context).getRenderingBo(controlId, ControlRenderingBo.class); + if (bo != null) { + this.showCustomThemeMenu(bo, customThemeName, context); + } + return true; + } + + final var themeName = this.themeSelectionByControlId.get(controlId); + if (themeName != null) { + this.themeManager().setCurrentTheme(themeName); + this.refreshContent(context); + return true; + } + + return false; + } + + @Override + public void onContentCleared(final OptionsModuleContext context) { + this.hideCustomThemeMenu(context); + this.themeSelectionByControlId.clear(); + this.customThemeSelectionByControlId.clear(); + } + + private List getCustomThemeNames() { + final var descriptors = this.storageService().listPath(CUSTOM_THEME_PATH, false); + final var names = new ArrayList(descriptors.size()); + for (final var descriptor : descriptors) { + if (descriptor != null && descriptor.name() != null && !descriptor.name().isBlank()) { + names.add(descriptor.name()); + } + } + names.sort(String::compareToIgnoreCase); + return List.copyOf(names); + } + + private void showCustomThemeMenu(final ControlRenderingBo bo, final String themeName, final OptionsModuleContext context) { + if (themeName.equals(this.activeCustomThemeName) + && this.optionsWindow(context).getRenderingBo(CUSTOM_THEME_MENU_ID, ControlRenderingBo.class) != null) { + this.hideCustomThemeMenu(context); + final var controlId = "OptionsModule_CustomTheme_" + sanitizeId(themeName); + final var control = this.optionsWindow(context).getRenderingBo(controlId, ControlRenderingBo.class); + if (control != null) { + final var currentTheme = this.themeManager().getCurrentTheme(); + control.setControlData(new ToggleButtonData(currentTheme != null && themeName.equals(currentTheme.getName()), true, null)); + this.optionsWindow(context).putRenderingBo(control); + } + this.optionsWindow(context).draw(); + return; + } + + this.hideCustomThemeMenu(context); + this.activeCustomThemeName = themeName; + + if (!(bo instanceof final RenderingBoHasDimension boDim)) { + return; + } + + final var items = new ArrayList(); + items.add(new PopupMenuItem(CUSTOM_THEME_MENU_SELECT, "Select", true)); + if (this.themeEditorSupportProvider.getIfAvailable() != null) { + items.add(new PopupMenuItem(CUSTOM_THEME_MENU_EDIT, "Edit", true)); + } + items.add(new PopupMenuItem(CUSTOM_THEME_MENU_DELETE, "Delete", true)); + + final var rowHeight = 32; + final var padding = 8; + final var width = Math.min(240, boDim.getWidth()); + final var height = (rowHeight * items.size()) + (padding * 2); + final var offset = this.contentScrollArea(context).getRelativePosition(); + final var x = offset.getX() + bo.getLocalX(); + final var y = offset.getY() + bo.getLocalY() + boDim.getHeight() + 6; + + final var data = new PopupMenuData(List.copyOf(items), null, null, null, rowHeight, padding); + final var popup = this.renderingBoPool().acquire(CUSTOM_THEME_MENU_ID, ControlRenderingBo.class) + .setType(PopupMenuControl.class) + .setFontSize(22) + .setVisible(true) + .setEnabled(true) + .setControlData(data) + .withDimensionAbsolute(width, height) + .setLayer(RenderingBoLayer.UI1) + .withPositionRelative(x, y); + this.optionsWindow(context).putRenderingBo(popup); + this.optionsWindow(context).draw(); + } + + private void handleCustomThemeMenuSelection(final Object payload, final OptionsModuleContext context) { + if (this.activeCustomThemeName == null) { + return; + } + + final String actionId; + if (payload instanceof final String id) { + actionId = id; + } + else if (payload instanceof final PopupMenuItem item) { + actionId = item.id(); + } + else { + return; + } + + switch (actionId) { + case CUSTOM_THEME_MENU_SELECT -> this.applyCustomTheme(this.activeCustomThemeName, context); + case CUSTOM_THEME_MENU_EDIT -> this.editCustomTheme(this.activeCustomThemeName, context); + case CUSTOM_THEME_MENU_DELETE -> this.deleteCustomTheme(this.activeCustomThemeName, context); + default -> { + return; + } + } + + this.refreshContent(context); + } + + private void hideCustomThemeMenu(final OptionsModuleContext context) { + this.optionsWindow(context).removeRenderingBo(CUSTOM_THEME_MENU_ID); + this.activeCustomThemeName = null; + } + + private void applyCustomTheme(final String themeName, final OptionsModuleContext context) { + final var theme = this.loadCustomTheme(themeName); + if (theme == null) { + return; + } + this.themeManager().setCurrentTheme(theme); + this.refreshContent(context); + } + + private void editCustomTheme(final String themeName, final OptionsModuleContext context) { + final var themeEditorSupport = this.themeEditorSupportProvider.getIfAvailable(); + if (themeEditorSupport == null) { + return; + } + final var theme = this.loadCustomTheme(themeName); + if (theme == null) { + return; + } + themeEditorSupport.openEdit(theme, this.optionsWindow(context)); + } + + private void deleteCustomTheme(final String themeName, final OptionsModuleContext context) { + this.storageService().deleteByPath(CUSTOM_THEME_PATH, themeName); + final var currentTheme = this.themeManager().getCurrentTheme(); + if (currentTheme != null && themeName.equalsIgnoreCase(currentTheme.getName())) { + this.themeManager().setCurrentTheme((String) null); + } + this.refreshContent(context); + } + + private Theme loadCustomTheme(final String themeName) { + return this.storageService().loadByPath(Theme.class, CUSTOM_THEME_PATH, themeName).orElse(null); + } + + private void openNewThemeEditor(final OptionsModuleContext context) { + final var themeEditorSupport = this.themeEditorSupportProvider.getIfAvailable(); + if (themeEditorSupport == null) { + return; + } + final var name = this.generateNewThemeName(); + themeEditorSupport.openNew(this.themeManager().getCurrentTheme(), name, this.optionsWindow(context)); + } + + private String generateNewThemeName() { + final var existing = this.getCustomThemeNames(); + final var base = "Custom Theme"; + if (!existing.contains(base)) { + return base; + } + var index = 2; + while (existing.contains(base + " " + index)) { + index++; + } + return base + " " + index; + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/OptionsVideoTab.java b/src/main/java/de/extio/game_engine/menu/OptionsVideoTab.java new file mode 100644 index 0000000..4511de3 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/OptionsVideoTab.java @@ -0,0 +1,287 @@ +package de.extio.game_engine.menu; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonData; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.event.UiControlEvent; +import de.extio.game_engine.renderer.model.options.VideoOptions.VideoOptionsVideoMode; + +final class OptionsVideoTab extends AbstractOptionsTab implements OptionsTab { + + static final String TAB_ID = "video"; + private static final String CONTROL_ID = "OptionsModule_Tab_Video"; + + private final Map framerateSelectionByControlId = new HashMap<>(); + private final Map screenSelectionByControlId = new HashMap<>(); + private final Map scaleFactorSelectionByControlId = new HashMap<>(); + + @Override + public String tabId() { + return TAB_ID; + } + + @Override + public String controlId() { + return CONTROL_ID; + } + + @Override + public String navigationLocalizationKey() { + return "ecyoa-84"; + } + + @Override + public String fallbackCaption() { + return "Video"; + } + + @Override + public int navigationOrder() { + return 30; + } + + @Override + public void render(final OptionsModuleContext context) { + this.framerateSelectionByControlId.clear(); + this.screenSelectionByControlId.clear(); + this.scaleFactorSelectionByControlId.clear(); + + var yPos = 10; + + var bo = this.renderingBoPool().acquire("OptionsModule_Video_Label_Mode", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-91", "Video Mode:")) + .setFontSize(48) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 80) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 90; + + final var currentMode = this.rendererControl().getVideoOptions().getVideoMode(); + + bo = this.renderingBoPool().acquire("OptionsModule_VideoMode_Window", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-92", "Windowed")) + .setFontSize(42) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_VideoModeGroup") + .setControlData(new ToggleButtonData(currentMode == VideoOptionsVideoMode.WINDOW, false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(300, 60) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + bo = this.renderingBoPool().acquire("OptionsModule_VideoMode_Borderless", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-93", "Borderless")) + .setFontSize(42) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_VideoModeGroup") + .setControlData(new ToggleButtonData(currentMode == VideoOptionsVideoMode.BORDERLESS, false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(300, 60) + .withPositionRelative(320, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + bo = this.renderingBoPool().acquire("OptionsModule_VideoMode_Fullscreen", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-94", "Fullscreen")) + .setFontSize(42) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_VideoModeGroup") + .setControlData(new ToggleButtonData(currentMode == VideoOptionsVideoMode.FULLSCREEN, false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(300, 60) + .withPositionRelative(630, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 100; + + bo = this.renderingBoPool().acquire("OptionsModule_Video_Label_Screen", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-105", "Screen:")) + .setFontSize(48) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 80) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 90; + + final var screenDevices = this.rendererControl().getScreenDevicesCount(); + final var currentScreen = this.rendererControl().getVideoOptions().getFullScreenNumber(); + + var screenXPos = 10; + for (var i = 0; i < screenDevices; i++) { + final var controlId = "OptionsModule_Video_Screen_" + i; + this.screenSelectionByControlId.put(controlId, i); + + bo = this.renderingBoPool().acquire(controlId, ControlRenderingBo.class) + .setCaption(String.valueOf(i + 1)) + .setFontSize(42) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_ScreenGroup") + .setControlData(new ToggleButtonData(currentScreen == i, false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(80, 60) + .withPositionRelative(screenXPos, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + screenXPos += 90; + } + + yPos += 100; + + bo = this.renderingBoPool().acquire("OptionsModule_Video_Label_ScaleFactor", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-95", "Scale Factor:")) + .setFontSize(48) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 80) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 90; + + final var currentScaleFactor = this.rendererControl().getVideoOptions().getScaleFactorModifier(); + final var scaleFactors = List.of(0.75, 0.90, 1.00, 1.15, 1.25); + + var scaleXPos = 10; + for (final var scaleFactor : scaleFactors) { + final var controlId = "OptionsModule_Video_ScaleFactor_" + (int) (scaleFactor * 100); + this.scaleFactorSelectionByControlId.put(controlId, scaleFactor); + + bo = this.renderingBoPool().acquire(controlId, ControlRenderingBo.class) + .setCaption((int) (scaleFactor * 100) + "%") + .setFontSize(42) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_ScaleFactorGroup") + .setControlData(new ToggleButtonData(Math.abs(currentScaleFactor - scaleFactor) < 0.01, false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(150, 60) + .withPositionRelative(scaleXPos, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + scaleXPos += 160; + } + + yPos += 100; + + bo = this.renderingBoPool().acquire("OptionsModule_Video_Label_Framerate", ControlRenderingBo.class) + .setCaption(this.localizationService().translate("ecyoa-96", "Target Framerate:")) + .setFontSize(48) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(800, 80) + .withPositionRelative(10, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + yPos += 90; + + final var currentFramerate = this.rendererControl().getVideoOptions().getFrameRateTarget(); + final var framerates = List.of(30, 60, 120, 144, 165, 200); + + var xPos = 10; + for (final var framerate : framerates) { + final var controlId = "OptionsModule_Video_Framerate_" + framerate; + this.framerateSelectionByControlId.put(controlId, framerate); + + bo = this.renderingBoPool().acquire(controlId, ControlRenderingBo.class) + .setCaption(framerate + " FPS") + .setFontSize(42) + .setType(ToggleButtonControl.class) + .setControlGroup("OptionsModule_FramerateGroup") + .setControlData(new ToggleButtonData(currentFramerate == framerate, false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(220, 60) + .withPositionRelative(xPos, yPos); + this.contentScrollArea(context).putRenderingBo(bo); + + xPos += 230; + } + } + + @Override + public boolean handle(final UiControlEvent event, final OptionsModuleContext context) { + final var controlId = event.getId(); + if (controlId == null) { + return false; + } + + switch (controlId) { + case "OptionsModule_VideoMode_Window" -> { + this.rendererControl().getVideoOptions().setVideoMode(VideoOptionsVideoMode.WINDOW); + this.rendererControl().applyVideoOptions(); + this.refreshContent(context); + return true; + } + case "OptionsModule_VideoMode_Borderless" -> { + this.rendererControl().getVideoOptions().setVideoMode(VideoOptionsVideoMode.BORDERLESS); + this.rendererControl().applyVideoOptions(); + this.refreshContent(context); + return true; + } + case "OptionsModule_VideoMode_Fullscreen" -> { + this.rendererControl().getVideoOptions().setVideoMode(VideoOptionsVideoMode.FULLSCREEN); + this.rendererControl().applyVideoOptions(); + this.refreshContent(context); + return true; + } + default -> { + } + } + + final var framerate = this.framerateSelectionByControlId.get(controlId); + if (framerate != null) { + this.rendererControl().getVideoOptions().setFrameRateTarget(framerate); + this.rendererControl().setFrameRate(framerate); + this.refreshContent(context); + return true; + } + + final var screenNumber = this.screenSelectionByControlId.get(controlId); + if (screenNumber != null) { + this.rendererControl().getVideoOptions().setFullScreenNumber(screenNumber); + this.rendererControl().applyVideoOptions(); + this.refreshContent(context); + return true; + } + + final var scaleFactor = this.scaleFactorSelectionByControlId.get(controlId); + if (scaleFactor != null) { + this.rendererControl().getVideoOptions().setScaleFactorModifier(scaleFactor); + this.rendererControl().setScaleFactorModifier(scaleFactor); + this.refreshContent(context); + return true; + } + + return false; + } + + @Override + public void onContentCleared(final OptionsModuleContext context) { + this.framerateSelectionByControlId.clear(); + this.screenSelectionByControlId.clear(); + this.scaleFactorSelectionByControlId.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/de/extio/game_engine/menu/ThemeEditorModule.java b/src/main/java/de/extio/game_engine/menu/ThemeEditorModule.java new file mode 100644 index 0000000..c69a791 --- /dev/null +++ b/src/main/java/de/extio/game_engine/menu/ThemeEditorModule.java @@ -0,0 +1,774 @@ +package de.extio.game_engine.menu; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import de.extio.game_engine.event.EventService; +import de.extio.game_engine.menu.HsbColorPickerModule.HsbColorPickerResponseEvent; +import de.extio.game_engine.module.AbstractClientModule; +import de.extio.game_engine.renderer.RendererControl; +import de.extio.game_engine.renderer.ThemeManager; +import de.extio.game_engine.renderer.container.ScrollArea; +import de.extio.game_engine.renderer.container.Window; +import de.extio.game_engine.renderer.model.RenderingBoHasDimension; +import de.extio.game_engine.renderer.model.RenderingBoLayer; +import de.extio.game_engine.renderer.model.Theme; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.PopupMenuItem; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SliderControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.SliderData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.TextfieldControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.TextfieldData; +import de.extio.game_engine.renderer.model.bo.DrawEffectRenderingBo; +import de.extio.game_engine.renderer.model.bo.DrawEffectRenderingBoEffects; +import de.extio.game_engine.renderer.model.bo.DrawFontRenderingBo; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.color.HSBColor; +import de.extio.game_engine.renderer.model.color.ImmutableRgbaColor; +import de.extio.game_engine.renderer.model.event.MouseClickEvent; +import de.extio.game_engine.renderer.model.event.UiControlEvent; +import de.extio.game_engine.renderer.work.RenderingBoPool; +import de.extio.game_engine.resource.StaticResourceService; +import de.extio.game_engine.spatial2.model.ImmutableCoordI2; +import de.extio.game_engine.storage.StorageService; + +public class ThemeEditorModule extends AbstractClientModule implements OptionsThemeEditorSupport { + + private static final List CUSTOM_THEME_PATH = List.of("customThemes"); + + private static final int WINDOW_WIDTH = 1200; + + private static final int WINDOW_HEIGHT = 820; + + private static final int ROW_HEIGHT = 60; + + private static final int LABEL_WIDTH = 320; + + private static final int CONTROL_WIDTH = 620; + + private static final int VALUE_WIDTH = 120; + + private static final int SPACING = 14; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private RenderingBoPool renderingBoPool; + + @Autowired + private EventService eventService; + + @Autowired + private ThemeManager themeManager; + + @Autowired + private StorageService storageService; + + @Autowired + private StaticResourceService staticResourceService; + + @Autowired + private HsbColorPickerModule hsbColorPickerModule; + + private Window editorWindow; + + private ScrollArea scrollArea; + + private Theme originalTheme; + + private String originalName; + + private String name; + + private String patternRendererName; + + private String font; + + private HSBColor borderOuter; + + private HSBColor borderInner; + + private HSBColor borderInnerDisabled; + + private HSBColor backgroundNormal; + + private HSBColor backgroundSelected; + + private HSBColor textNormal; + + private HSBColor textDisabled; + + private HSBColor selectionPrimary; + + private HSBColor selectionSecondary; + + private HSBColor windowBackground; + + private float hoverBrightnessAdjustment; + + private float pressedBrightnessAdjustment; + + private final Map colorSlotByControlId = new HashMap<>(); + + private PopupTarget popupTarget; + + private String pendingColorRequestId; + + @Override + public void onLoad() { + this.editorWindow = this.applicationContext.getBean(Window.class); + this.editorWindow.setNormalizedDimension(ImmutableCoordI2.create(WINDOW_WIDTH, WINDOW_HEIGHT)); + this.editorWindow.setNormalizedPosition(this.centeredPosition(this.editorWindow.getNormalizedDimension())); + this.editorWindow.setDraggable(true); + this.editorWindow.setCloseButton(true); + this.editorWindow.setOnCloseAction(this::onCancel); + + this.scrollArea = this.applicationContext.getBean(ScrollArea.class); + this.editorWindow.addComponent(this.scrollArea); + } + + @Override + public void onUnload() { + this.getModuleService().unloadModule(this.editorWindow.getId()); + } + + @Override + public void onActivate() { + this.setupEditor(); + this.getModuleService().changeActiveState(this.editorWindow.getId(), true); + this.eventService.register(UiControlEvent.class, this.getId(), this::onUiControlEvent); + this.eventService.register(HsbColorPickerResponseEvent.class, this.getId(), this::onColorPickerResponse); + this.eventService.register(MouseClickEvent.class, this.getId(), event -> { + if (event.isPressed() && (event.getButton() == 1 || event.getButton() == 3) && this.popupTarget != null) { + this.hidePopup(); + this.editorWindow.draw(); + } + }); + } + + @Override + public void onDeactivate() { + this.eventService.unregisterAll(this.getId()); + this.getModuleService().changeActiveState(this.editorWindow.getId(), false); + } + + @Override + public void onShow() { + this.getModuleService().changeDisplayState(this.editorWindow.getId(), true); + } + + @Override + public void onHide() { + this.getModuleService().changeDisplayState(this.editorWindow.getId(), false); + } + + @Override + public void openNew(final Theme baseTheme, final String proposedName, final Window parentWindow) { + this.originalTheme = this.themeManager.getCurrentTheme(); + final var template = baseTheme != null ? baseTheme : this.themeManager.getCurrentTheme(); + this.originalName = null; + this.name = proposedName; + this.initializeFromTheme(template); + // apply the template theme immediately for preview + this.applyCurrentThemePreview(); + this.editorWindow.setParent(parentWindow); + this.getModuleService().changeActiveState(this.getId(), true); + this.getModuleService().changeDisplayState(this.getId(), true); + } + + @Override + public void openEdit(final Theme theme, final Window parentWindow) { + if (theme == null) { + return; + } + this.originalTheme = this.themeManager.getCurrentTheme(); + this.originalName = theme.getName(); + this.name = theme.getName(); + this.initializeFromTheme(theme); + // apply the selected theme immediately for preview + this.applyCurrentThemePreview(); + this.editorWindow.setParent(parentWindow); + this.getModuleService().changeActiveState(this.getId(), true); + this.getModuleService().changeDisplayState(this.getId(), true); + } + + private void initializeFromTheme(final Theme theme) { + this.patternRendererName = theme.getPatternRendererName(); + this.font = theme.getFont(); + this.borderOuter = theme.getBorderOuter(); + this.borderInner = theme.getBorderInner(); + this.borderInnerDisabled = theme.getBorderInnerDisabled(); + this.backgroundNormal = theme.getBackgroundNormal(); + this.backgroundSelected = theme.getBackgroundSelected(); + this.textNormal = theme.getTextNormal(); + this.textDisabled = theme.getTextDisabled(); + this.selectionPrimary = theme.getSelectionPrimary(); + this.selectionSecondary = theme.getSelectionSecondary(); + this.windowBackground = theme.getWindowBackground(); + this.hoverBrightnessAdjustment = theme.getHoverBrightnessAdjustment(); + this.pressedBrightnessAdjustment = theme.getPressedBrightnessAdjustment(); + } + + private void setupEditor() { + this.editorWindow.clearRenderingBos(); + for (final var boId : this.scrollArea.getRenderingBoIds().toArray(new String[0])) { + this.scrollArea.removeRenderingBo(boId); + } + this.colorSlotByControlId.clear(); + this.popupTarget = null; + this.pendingColorRequestId = null; + + final var contentWidth = WINDOW_WIDTH - Window.MARGIN_LEFT - Window.MARGIN_RIGHT; + final var bottomButtonsY = WINDOW_HEIGHT - Window.MARGIN_BOTTOM - 60; + + this.scrollArea.setRelativePosition(ImmutableCoordI2.create(Window.MARGIN_LEFT, Window.MARGIN_TOP)); + this.scrollArea.setDimension(ImmutableCoordI2.create(contentWidth, bottomButtonsY - Window.MARGIN_TOP - SPACING)); + + var yPos = 10; + + var bo = this.renderingBoPool.acquire("ThemeEditor_Label_Header", DrawFontRenderingBo.class) + .setText("Theme Editor") + .setSize(36) + .setAlignment(HorizontalAlignment.LEFT) + .withDimensionAbsolute(600, 60) + .withPositionRelative(10, yPos); + this.scrollArea.putRenderingBo(bo); + + yPos += 80; + + yPos = this.addTextFieldRow("Name", "ThemeEditor_Field_Name", this.name, yPos); + yPos = this.addPopupRow("Pattern Renderer", "ThemeEditor_Field_Pattern", this.patternRendererName, yPos); + yPos = this.addPopupRow("Font", "ThemeEditor_Field_Font", this.font, yPos); + yPos = this.addSliderRow("Hover Brightness", "ThemeEditor_Field_Hover", this.hoverBrightnessAdjustment, yPos); + yPos = this.addSliderRow("Pressed Brightness", "ThemeEditor_Field_Pressed", this.pressedBrightnessAdjustment, yPos); + + yPos += 20; + + yPos = this.addColorRow("Border Outer", "ThemeEditor_Color_BorderOuter", this.borderOuter, yPos); + yPos = this.addColorRow("Border Inner", "ThemeEditor_Color_BorderInner", this.borderInner, yPos); + yPos = this.addColorRow("Border Inner Disabled", "ThemeEditor_Color_BorderInnerDisabled", this.borderInnerDisabled, yPos); + yPos = this.addColorRow("Background Normal", "ThemeEditor_Color_BackgroundNormal", this.backgroundNormal, yPos); + yPos = this.addColorRow("Background Selected", "ThemeEditor_Color_BackgroundSelected", this.backgroundSelected, yPos); + yPos = this.addColorRow("Text Normal", "ThemeEditor_Color_TextNormal", this.textNormal, yPos); + yPos = this.addColorRow("Text Disabled", "ThemeEditor_Color_TextDisabled", this.textDisabled, yPos); + yPos = this.addColorRow("Selection Primary", "ThemeEditor_Color_SelectionPrimary", this.selectionPrimary, yPos); + yPos = this.addColorRow("Selection Secondary", "ThemeEditor_Color_SelectionSecondary", this.selectionSecondary, yPos); + yPos = this.addColorRow("Window Background", "ThemeEditor_Color_WindowBackground", this.windowBackground, yPos); + + final var buttonWidth = 200; + final var buttonHeight = 60; + + bo = this.renderingBoPool.acquire("ThemeEditor_Button_Save", ControlRenderingBo.class) + .setCaption("Save") + .setFontSize(32) + .setType(ButtonControl.class) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(buttonWidth, buttonHeight) + .withPositionRelative(WINDOW_WIDTH - Window.MARGIN_RIGHT - buttonWidth * 2 - SPACING, bottomButtonsY); + this.editorWindow.putRenderingBo(bo); + + bo = this.renderingBoPool.acquire("ThemeEditor_Button_Cancel", ControlRenderingBo.class) + .setCaption("Cancel") + .setFontSize(32) + .setType(ButtonControl.class) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(buttonWidth, buttonHeight) + .withPositionRelative(WINDOW_WIDTH - Window.MARGIN_RIGHT - buttonWidth, bottomButtonsY); + this.editorWindow.putRenderingBo(bo); + + this.editorWindow.draw(); + } + + private int addTextFieldRow(final String label, final String fieldId, final String value, final int yPos) { + var bo = this.renderingBoPool.acquire("ThemeEditor_Label_" + fieldId, ControlRenderingBo.class) + .setCaption(label) + .setFontSize(30) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(LABEL_WIDTH, ROW_HEIGHT) + .withPositionRelative(10, yPos + 10); + this.scrollArea.putRenderingBo(bo); + + bo = this.renderingBoPool.acquire(fieldId, ControlRenderingBo.class) + .setCaption(value != null ? value : "") + .setFontSize(28) + .setType(TextfieldControl.class) + .setControlData(new TextfieldData(false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(CONTROL_WIDTH, ROW_HEIGHT) + .withPositionRelative(10 + LABEL_WIDTH + SPACING, yPos); + this.scrollArea.putRenderingBo(bo); + + return yPos + ROW_HEIGHT + SPACING; + } + + private int addPopupRow(final String label, final String fieldId, final String value, final int yPos) { + var bo = this.renderingBoPool.acquire("ThemeEditor_Label_" + fieldId, ControlRenderingBo.class) + .setCaption(label) + .setFontSize(30) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(LABEL_WIDTH, ROW_HEIGHT) + .withPositionRelative(10, yPos + 10); + this.scrollArea.putRenderingBo(bo); + + bo = this.renderingBoPool.acquire(fieldId, ControlRenderingBo.class) + .setCaption(value != null ? value : "") + .setFontSize(28) + .setType(ButtonControl.class) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(CONTROL_WIDTH, ROW_HEIGHT) + .withPositionRelative(10 + LABEL_WIDTH + SPACING, yPos); + this.scrollArea.putRenderingBo(bo); + + return yPos + ROW_HEIGHT + SPACING; + } + + private int addSliderRow(final String label, final String fieldId, final float value, final int yPos) { + var bo = this.renderingBoPool.acquire("ThemeEditor_Label_" + fieldId, ControlRenderingBo.class) + .setCaption(label) + .setFontSize(30) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(LABEL_WIDTH, ROW_HEIGHT) + .withPositionRelative(10, yPos + 10); + this.scrollArea.putRenderingBo(bo); + + bo = this.renderingBoPool.acquire(fieldId, ControlRenderingBo.class) + .setType(SliderControl.class) + .setControlData(new SliderData(true, value, value, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(CONTROL_WIDTH, ROW_HEIGHT) + .withPositionRelative(10 + LABEL_WIDTH + SPACING, yPos); + this.scrollArea.putRenderingBo(bo); + + bo = this.renderingBoPool.acquire(fieldId + "_Value", ControlRenderingBo.class) + .setCaption(this.formatValue(value)) + .setFontSize(26) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(VALUE_WIDTH, ROW_HEIGHT) + .withPositionRelative(10 + LABEL_WIDTH + SPACING + CONTROL_WIDTH + SPACING, yPos + 10); + this.scrollArea.putRenderingBo(bo); + + return yPos + ROW_HEIGHT + SPACING; + } + + private int addColorRow(final String label, final String fieldId, final HSBColor color, final int yPos) { + var bo = this.renderingBoPool.acquire("ThemeEditor_Label_" + fieldId, ControlRenderingBo.class) + .setCaption(label) + .setFontSize(30) + .setType(LabelControl.class) + .setControlData(new LabelData(null, null, HorizontalAlignment.LEFT)) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(LABEL_WIDTH, ROW_HEIGHT) + .withPositionRelative(10, yPos + 10); + this.scrollArea.putRenderingBo(bo); + + final int rectWidth = CONTROL_WIDTH - VALUE_WIDTH - SPACING; + final DrawEffectRenderingBo rectBo = this.renderingBoPool.acquire("ThemeEditor_ColorRect_" + fieldId, DrawEffectRenderingBo.class); + rectBo.setEffect(DrawEffectRenderingBoEffects.RECT_FILLED); + rectBo.setRelativeCoordinates(List.of(ImmutableCoordI2.create(rectWidth, ROW_HEIGHT))); + rectBo.setColor(this.toRgbaColor(color)); + rectBo.withPositionRelative(10 + LABEL_WIDTH + SPACING, yPos); + this.scrollArea.putRenderingBo(rectBo); + + bo = this.renderingBoPool.acquire(fieldId, ControlRenderingBo.class) + .setCaption("Edit") + .setFontSize(26) + .setType(ButtonControl.class) + .setControlData(new ControlRenderingBo.ButtonData(null, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(VALUE_WIDTH, ROW_HEIGHT) + .withPositionRelative(10 + LABEL_WIDTH + SPACING + rectWidth + SPACING, yPos); + this.scrollArea.putRenderingBo(bo); + + this.colorSlotByControlId.put(fieldId, this.resolveColorSlot(fieldId)); + + return yPos + ROW_HEIGHT + SPACING; + } + + private void onUiControlEvent(final UiControlEvent event) { + if (event.getId() == null) { + return; + } + + switch (event.getId()) { + case "ThemeEditor_Button_Save" -> this.onSave(); + case "ThemeEditor_Button_Cancel" -> this.onCancel(); + case "ThemeEditor_Field_Name" -> this.onNameChanged(event.getPayload()); + case "ThemeEditor_Field_Pattern" -> this.openPatternPopup(); + case "ThemeEditor_Field_Font" -> this.openFontPopup(); + case "ThemeEditor_Field_Hover" -> this.onHoverChanged(event.getPayload()); + case "ThemeEditor_Field_Pressed" -> this.onPressedChanged(event.getPayload()); + case "ThemeEditor_Popup_Menu" -> this.onPopupSelected(event.getPayload()); + default -> this.handleColorButton(event.getId()); + } + } + + private void onNameChanged(final Object payload) { + if (payload instanceof final String text) { + this.name = text.trim(); + } + } + + private void onHoverChanged(final Object payload) { + if (payload instanceof final Double value) { + this.hoverBrightnessAdjustment = this.clamp01(value.floatValue()); + this.updateValueLabel("ThemeEditor_Field_Hover_Value", this.hoverBrightnessAdjustment); + this.updateSliderValue("ThemeEditor_Field_Hover", value); + this.editorWindow.draw(); + this.applyCurrentThemePreview(); + } + } + + private void onPressedChanged(final Object payload) { + if (payload instanceof final Double value) { + this.pressedBrightnessAdjustment = this.clamp01(value.floatValue()); + this.updateValueLabel("ThemeEditor_Field_Pressed_Value", this.pressedBrightnessAdjustment); + this.updateSliderValue("ThemeEditor_Field_Pressed", value); + this.editorWindow.draw(); + this.applyCurrentThemePreview(); + } + } + + private void handleColorButton(final String controlId) { + final var slot = this.colorSlotByControlId.get(controlId); + if (slot == null) { + return; + } + final var color = this.getColor(slot); + final var requestId = "ThemeEditor_ColorPicker_" + slot.name(); + this.pendingColorRequestId = requestId; + this.hsbColorPickerModule.open(requestId, slot.displayName, color, this.editorWindow); + } + + private void onColorPickerResponse(final HsbColorPickerResponseEvent event) { + if (this.pendingColorRequestId == null || !this.pendingColorRequestId.equals(event.requestId())) { + return; + } + if (!event.confirmed() || event.color() == null) { + return; + } + final var slot = ColorSlot.valueOf(event.requestId().substring("ThemeEditor_ColorPicker_".length())); + this.setColor(slot, event.color()); + this.updateColorButton(slot); + this.applyCurrentThemePreview(); + this.editorWindow.draw(); + } + + private void openPatternPopup() { + final List items = this.themeManager.getPatternRendererNames(); + if (this.popupTarget == null) { + this.popupTarget = PopupTarget.PATTERN_RENDERER; + this.showPopup(items, "ThemeEditor_Field_Pattern"); + } + else { + this.hidePopup(); + this.editorWindow.draw(); + } + } + + private void openFontPopup() { + final List items = this.getAvailableFonts(); + if (this.popupTarget == null) { + this.popupTarget = PopupTarget.FONT; + this.showPopup(items, "ThemeEditor_Field_Font"); + } + else { + this.hidePopup(); + this.editorWindow.draw(); + } + } + + private void onPopupSelected(final Object payload) { + final String value; + if (payload instanceof final String id) { + value = id; + } + else if (payload instanceof final PopupMenuItem item) { + value = item.id(); + } + else { + return; + } + if (this.popupTarget == PopupTarget.PATTERN_RENDERER) { + this.patternRendererName = value; + this.updateButtonCaption("ThemeEditor_Field_Pattern", value); + } + else if (this.popupTarget == PopupTarget.FONT) { + this.font = value; + this.updateButtonCaption("ThemeEditor_Field_Font", value); + } + + this.hidePopup(); + this.editorWindow.draw(); + this.applyCurrentThemePreview(); + } + + private void showPopup(final List items, final String anchorId) { + if (items == null || items.isEmpty()) { + return; + } + final var bo = this.editorWindow.getRenderingBo(anchorId, ControlRenderingBo.class); + if (!(bo instanceof final RenderingBoHasDimension boDim)) { + return; + } + final var popupItems = items.stream() + .map(item -> new PopupMenuItem(item, item, true)) + .toList(); + final var rowHeight = 32; + final var padding = 8; + final var width = Math.min(420, boDim.getWidth()); + final var height = (rowHeight * popupItems.size()) + (padding * 2); + + final var offset = this.scrollArea.getRelativePosition(); + final var x = offset.getX() + bo.getLocalX(); + final var y = offset.getY() + bo.getLocalY() + boDim.getHeight() + 6; + + final var data = new PopupMenuData(popupItems, null, null, null, rowHeight, padding); + final var popup = this.renderingBoPool.acquire("ThemeEditor_Popup_Menu", ControlRenderingBo.class) + .setType(PopupMenuControl.class) + .setFontSize(22) + .setVisible(true) + .setEnabled(true) + .setControlData(data) + .withDimensionAbsolute(width, height) + .setLayer(RenderingBoLayer.UI1) + .withPositionRelative(x, y); + this.editorWindow.putRenderingBo(popup); + this.editorWindow.draw(); + } + + private void hidePopup() { + this.editorWindow.removeRenderingBo("ThemeEditor_Popup_Menu"); + this.popupTarget = null; + } + + private void updateButtonCaption(final String id, final String caption) { + final var bo = this.editorWindow.getRenderingBo(id, ControlRenderingBo.class); + if (bo != null) { + bo.setCaption(caption != null ? caption : ""); + this.editorWindow.putRenderingBo(bo); + } + } + + private void updateValueLabel(final String id, final float value) { + final var bo = this.editorWindow.getRenderingBo(id, ControlRenderingBo.class); + if (bo != null) { + bo.setCaption(this.formatValue(value)); + this.editorWindow.putRenderingBo(bo); + } + } + + private void updateSliderValue(final String id, final double value) { + final var bo = this.editorWindow.getRenderingBo(id, ControlRenderingBo.class); + if (bo != null) { + final var sliderData = new SliderData(true, value, value, null); + bo.setControlData(sliderData); + this.editorWindow.putRenderingBo(bo); + } + } + + private void updateColorButton(final ColorSlot slot) { + final var controlId = slot.controlId; + final var rectBo = this.editorWindow.getRenderingBo("ThemeEditor_ColorRect_" + controlId, DrawEffectRenderingBo.class); + if (rectBo != null) { + rectBo.setColor(this.toRgbaColor(this.getColor(slot))); + this.editorWindow.putRenderingBo(rectBo); + } + } + + private List getAvailableFonts() { + final var fonts = new ArrayList(); + for (final var name : this.staticResourceService.listPath(List.of("renderer"))) { + final var lower = name.toLowerCase(); + if (lower.endsWith(".ttf") || lower.endsWith(".otf")) { + fonts.add(name); + } + } + fonts.sort(String::compareToIgnoreCase); + return fonts; + } + + private void onSave() { + if (this.name == null || this.name.isBlank()) { + return; + } + final var theme = new Theme( + this.name.trim(), + this.patternRendererName, + this.font, + this.borderOuter, + this.borderInner, + this.borderInnerDisabled, + this.backgroundNormal, + this.backgroundSelected, + this.textNormal, + this.textDisabled, + this.selectionPrimary, + this.selectionSecondary, + this.windowBackground, + this.hoverBrightnessAdjustment, + this.pressedBrightnessAdjustment); + this.storageService.store(CUSTOM_THEME_PATH, theme.getName(), theme); + if (this.originalName != null && !this.originalName.equalsIgnoreCase(theme.getName())) { + this.storageService.deleteByPath(CUSTOM_THEME_PATH, this.originalName); + } + this.themeManager.setCurrentTheme(theme); + this.eventService.fire(new OptionsThemeEditorSavedEvent(true, theme)); + this.close(); + } + + private void onCancel() { + if (this.originalTheme != null) { + this.themeManager.setCurrentTheme(this.originalTheme); + } + this.eventService.fire(new OptionsThemeEditorSavedEvent(false, null)); + this.close(); + } + + private void applyCurrentThemePreview() { + final var previewTheme = new Theme( + this.name != null && !this.name.isBlank() ? this.name : "Preview", + this.patternRendererName, + this.font, + this.borderOuter, + this.borderInner, + this.borderInnerDisabled, + this.backgroundNormal, + this.backgroundSelected, + this.textNormal, + this.textDisabled, + this.selectionPrimary, + this.selectionSecondary, + this.windowBackground, + this.hoverBrightnessAdjustment, + this.pressedBrightnessAdjustment); + this.themeManager.setCurrentTheme(previewTheme); + } + + private void close() { + this.getModuleService().changeActiveState(this.getId(), false); + this.getModuleService().changeDisplayState(this.getId(), false); + } + + private float clamp01(final float value) { + return Math.max(0.0f, Math.min(1.0f, value)); + } + + private String formatValue(final float value) { + return String.format("%.2f", value); + } + + private HSBColor getColor(final ColorSlot slot) { + return switch (slot) { + case BORDER_OUTER -> this.borderOuter; + case BORDER_INNER -> this.borderInner; + case BORDER_INNER_DISABLED -> this.borderInnerDisabled; + case BACKGROUND_NORMAL -> this.backgroundNormal; + case BACKGROUND_SELECTED -> this.backgroundSelected; + case TEXT_NORMAL -> this.textNormal; + case TEXT_DISABLED -> this.textDisabled; + case SELECTION_PRIMARY -> this.selectionPrimary; + case SELECTION_SECONDARY -> this.selectionSecondary; + case WINDOW_BACKGROUND -> this.windowBackground; + }; + } + + private void setColor(final ColorSlot slot, final HSBColor color) { + switch (slot) { + case BORDER_OUTER -> this.borderOuter = color; + case BORDER_INNER -> this.borderInner = color; + case BORDER_INNER_DISABLED -> this.borderInnerDisabled = color; + case BACKGROUND_NORMAL -> this.backgroundNormal = color; + case BACKGROUND_SELECTED -> this.backgroundSelected = color; + case TEXT_NORMAL -> this.textNormal = color; + case TEXT_DISABLED -> this.textDisabled = color; + case SELECTION_PRIMARY -> this.selectionPrimary = color; + case SELECTION_SECONDARY -> this.selectionSecondary = color; + case WINDOW_BACKGROUND -> this.windowBackground = color; + } + } + + private ImmutableRgbaColor toRgbaColor(final HSBColor color) { + return new ImmutableRgbaColor(color.toColor()); + } + + private ColorSlot resolveColorSlot(final String controlId) { + return switch (controlId) { + case "ThemeEditor_Color_BorderOuter" -> ColorSlot.BORDER_OUTER; + case "ThemeEditor_Color_BorderInner" -> ColorSlot.BORDER_INNER; + case "ThemeEditor_Color_BorderInnerDisabled" -> ColorSlot.BORDER_INNER_DISABLED; + case "ThemeEditor_Color_BackgroundNormal" -> ColorSlot.BACKGROUND_NORMAL; + case "ThemeEditor_Color_BackgroundSelected" -> ColorSlot.BACKGROUND_SELECTED; + case "ThemeEditor_Color_TextNormal" -> ColorSlot.TEXT_NORMAL; + case "ThemeEditor_Color_TextDisabled" -> ColorSlot.TEXT_DISABLED; + case "ThemeEditor_Color_SelectionPrimary" -> ColorSlot.SELECTION_PRIMARY; + case "ThemeEditor_Color_SelectionSecondary" -> ColorSlot.SELECTION_SECONDARY; + case "ThemeEditor_Color_WindowBackground" -> ColorSlot.WINDOW_BACKGROUND; + default -> null; + }; + } + + private ImmutableCoordI2 centeredPosition(final de.extio.game_engine.spatial2.model.CoordI2 dimension) { + final var referenceResolution = RendererControl.REFERENCE_RESOLUTION; + final var x = Math.max(0, (referenceResolution.getX() - dimension.getX()) / 2); + final var y = Math.max(0, (referenceResolution.getY() - dimension.getY()) / 2); + return ImmutableCoordI2.create(x, y); + } + + private enum PopupTarget { + PATTERN_RENDERER, + FONT + } + + private enum ColorSlot { + + BORDER_OUTER("ThemeEditor_Color_BorderOuter", "Border Outer"), + BORDER_INNER("ThemeEditor_Color_BorderInner", "Border Inner"), + BORDER_INNER_DISABLED("ThemeEditor_Color_BorderInnerDisabled", "Border Inner Disabled"), + BACKGROUND_NORMAL("ThemeEditor_Color_BackgroundNormal", "Background Normal"), + BACKGROUND_SELECTED("ThemeEditor_Color_BackgroundSelected", "Background Selected"), + TEXT_NORMAL("ThemeEditor_Color_TextNormal", "Text Normal"), + TEXT_DISABLED("ThemeEditor_Color_TextDisabled", "Text Disabled"), + SELECTION_PRIMARY("ThemeEditor_Color_SelectionPrimary", "Selection Primary"), + SELECTION_SECONDARY("ThemeEditor_Color_SelectionSecondary", "Selection Secondary"), + WINDOW_BACKGROUND("ThemeEditor_Color_WindowBackground", "Window Background"); + + private final String controlId; + + private final String displayName; + + ColorSlot(final String controlId, final String displayName) { + this.controlId = controlId; + this.displayName = displayName; + } + } +} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index eca9fd4..1041b78 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -7,3 +7,4 @@ de.extio.game_engine.module.ModuleServiceAutoConfiguration de.extio.game_engine.i18n.LocalizationAutoConfiguration de.extio.game_engine.resource.StaticResourceAutoConfiguration de.extio.game_engine.audio.AudioAutoConfiguration +de.extio.game_engine.menu.MenuAutoConfiguration