feat(theme): enhance theme management with storage capabilities and add unit tests

This commit is contained in:
extio-de
2025-12-28 13:24:02 +01:00
parent 99eb8bc46f
commit b0b9d9b4f5
7 changed files with 398 additions and 35 deletions

View File

@@ -1,12 +1,20 @@
package de.extio.game_engine.renderer;
import java.util.List;
import java.util.Optional;
import de.extio.game_engine.resource.StaticResource;
import de.extio.game_engine.renderer.g2d.theme.Theme;
public interface ThemeManager {
List<String> getAvailableThemeNames();
Optional<Theme> loadThemeFromStorage(String themeName);
void saveThemeToStorage(Theme theme);
Optional<Theme> loadThemeFromStaticResource(StaticResource themeResource);
Theme getCurrentTheme();

View File

@@ -5,7 +5,6 @@ import java.util.Map;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
@@ -26,6 +25,7 @@ import de.extio.game_engine.renderer.g2d.theme.Theme;
import de.extio.game_engine.renderer.g2d.theme.UrbanThemeFactoryBean;
import de.extio.game_engine.renderer.g2d.theme.VintageThemeFactoryBean;
import de.extio.game_engine.resource.StaticResourceService;
import de.extio.game_engine.storage.StorageService;
@AutoConfiguration
@ConditionalOnProperty(name = "game-engine.renderer.enabled", havingValue = "true", matchIfMissing = true)
@@ -113,10 +113,11 @@ public class G2DAutoConfiguration {
@Bean
G2DThemeManager g2dThemeManager(
final StaticResourceService staticResourceService,
final StorageService storageService,
final List<PatternRenderer> patternRendererList,
final Map<String, Theme> themes,
@Qualifier("spacecraftTheme") final Theme defaultTheme) {
return new G2DThemeManager(staticResourceService, patternRendererList, themes, defaultTheme);
@Value("${game-engine.renderer.default-theme:urbanTheme}") final String defaultThemeName) {
return new G2DThemeManager(staticResourceService, storageService, patternRendererList, themes, defaultThemeName);
}
}

View File

@@ -2,7 +2,9 @@ package de.extio.game_engine.renderer.g2d.theme;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -11,15 +13,21 @@ import org.slf4j.LoggerFactory;
import de.extio.game_engine.renderer.ThemeManager;
import de.extio.game_engine.renderer.g2d.G2DRenderer;
import de.extio.game_engine.resource.StaticResource;
import de.extio.game_engine.resource.StaticResourceService;
import de.extio.game_engine.storage.StorageService;
public class G2DThemeManager implements ThemeManager {
private static final Logger LOGGER = LoggerFactory.getLogger(G2DThemeManager.class);
private static final List<String> THEME_STORAGE_PATH = List.of("gameEngine", "themes");
private static final String THEME_STORAGE_NAME = "lastTheme";
private G2DRenderer g2dRenderer;
private final StaticResourceService staticResourceService;
private final StorageService storageService;
private final Map<String, PatternRenderer> patternRenderers;
private final Map<String, Theme> themes;
@@ -29,20 +37,48 @@ public class G2DThemeManager implements ThemeManager {
public G2DThemeManager(
final StaticResourceService staticResourceService,
final StorageService storageService,
final List<PatternRenderer> patternRendererList,
final Map<String, Theme> themes,
final Theme defaultTheme) {
final String defaultThemeName) {
this.staticResourceService = staticResourceService;
this.storageService = storageService;
this.patternRenderers = patternRendererList.stream()
.collect(Collectors.toMap(
renderer -> renderer.getClass().getSimpleName().substring(0, 1).toLowerCase() + renderer.getClass().getSimpleName().substring(1),
Function.identity()));
this.themes = Objects.requireNonNull(themes, "themes cannot be null");
this.defaultTheme = Objects.requireNonNull(defaultTheme, "defaultTheme cannot be null");
this.themes = new ConcurrentHashMap<>(Objects.requireNonNull(themes, "themes cannot be null"));
this.defaultTheme = Objects.requireNonNull(this.themes.get(defaultThemeName), "defaultTheme cannot be null");
LOGGER.info("G2DThemeManager initialized with {} pattern renderers: {}", this.patternRenderers.size(), this.patternRenderers.keySet());
LOGGER.info("G2DThemeManager initialized with {} themes: {}", this.themes.size(), this.themes.keySet());
this.currentTheme = this.defaultTheme;
this.loadLastUsedTheme();
}
private void registerTheme(final Theme theme) {
if (theme == null || theme.getName() == null || theme.getName().isBlank()) {
return;
}
this.themes.put(theme.getName(), theme);
}
private void loadLastUsedTheme() {
if (this.storageService == null) {
return;
}
try {
final Optional<Theme> persistedTheme = this.storageService.loadByPath(Theme.class, THEME_STORAGE_PATH, THEME_STORAGE_NAME);
if (persistedTheme.isPresent()) {
this.currentTheme = persistedTheme.get();
this.registerTheme(this.currentTheme);
LOGGER.info("Loaded last used theme: {}", this.currentTheme.getName());
return;
}
}
catch (final Exception e) {
LOGGER.warn("Could not load last used theme. Using default.", e);
}
}
@Override
@@ -54,6 +90,44 @@ public class G2DThemeManager implements ThemeManager {
.toList();
}
@Override
public Optional<Theme> loadThemeFromStorage(final String themeName) {
if (themeName == null || themeName.isBlank()) {
throw new IllegalArgumentException("themeName cannot be null/blank");
}
if (this.storageService == null) {
return Optional.empty();
}
final Optional<Theme> loaded = this.storageService.loadByPath(Theme.class, THEME_STORAGE_PATH, themeName);
loaded.ifPresent(this::registerTheme);
return loaded;
}
@Override
public void saveThemeToStorage(final Theme theme) {
if (theme == null) {
throw new IllegalArgumentException("theme cannot be null");
}
if (theme.getName() == null || theme.getName().isBlank()) {
throw new IllegalArgumentException("theme.name cannot be null/blank");
}
if (this.storageService == null) {
throw new IllegalStateException("storageService not available");
}
this.storageService.store(THEME_STORAGE_PATH, theme.getName(), theme);
}
@Override
public Optional<Theme> loadThemeFromStaticResource(final StaticResource themeResource) {
Objects.requireNonNull(themeResource, "themeResource");
if (this.staticResourceService == null) {
return Optional.empty();
}
final Optional<Theme> loaded = this.staticResourceService.loadByPath(Theme.class, themeResource);
loaded.ifPresent(this::registerTheme);
return loaded;
}
@Override
public void setCurrentTheme(final String themeName) {
if (themeName == null || themeName.isBlank()) {
@@ -94,14 +168,40 @@ public class G2DThemeManager implements ThemeManager {
}
this.currentTheme = theme;
this.registerTheme(theme);
LOGGER.info("Theme switched to: {}", theme.getName());
this.persistLastUsedTheme(theme);
this.g2dRenderer.reset();
if (this.g2dRenderer != null) {
this.g2dRenderer.reset();
}
}
private void persistLastUsedTheme(final Theme theme) {
if (this.storageService == null) {
return;
}
try {
this.storageService.store(THEME_STORAGE_PATH, THEME_STORAGE_NAME, theme);
}
catch (final Exception e) {
LOGGER.warn("Could not persist last used theme", e);
}
}
public Theme loadTheme(final String themeName) {
Objects.requireNonNull(this.staticResourceService, "staticResourceService");
LOGGER.warn("Theme loading from StaticResourceService not yet implemented. Requested theme: {}. Using default theme.", themeName);
try {
final Optional<Theme> fromStorage = this.loadThemeFromStorage(themeName);
if (fromStorage.isPresent()) {
LOGGER.info("Loaded theme '{}' from storage", themeName);
return fromStorage.get();
}
}
catch (final Exception e) {
LOGGER.warn("Could not load theme '{}' from storage. Using default.", themeName, e);
}
LOGGER.warn("Theme '{}' not found in storage. Using default theme.", themeName);
return this.defaultTheme;
}

View File

@@ -3,6 +3,9 @@ package de.extio.game_engine.renderer.g2d.theme;
import java.awt.Color;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Represents a color in HSB (Hue, Saturation, Brightness) color space.
* This allows for easier runtime color manipulation (e.g., brightening on hover).
@@ -20,7 +23,11 @@ public final class HSBColor {
* @param saturation Saturation component (0.0 to 1.0)
* @param brightness Brightness component (0.0 to 1.0)
*/
public HSBColor(final float hue, final float saturation, final float brightness) {
@JsonCreator
public HSBColor(
@JsonProperty("hue") final float hue,
@JsonProperty("saturation") final float saturation,
@JsonProperty("brightness") final float brightness) {
this.hue = clamp(hue);
this.saturation = clamp(saturation);
this.brightness = clamp(brightness);

View File

@@ -1,5 +1,8 @@
package de.extio.game_engine.renderer.g2d.theme;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Represents a complete UI theme with all color definitions and pattern renderer.
* Themes are element-based rather than component-based, allowing consistent styling across different components.
@@ -33,21 +36,54 @@ public class Theme {
private final float hoverBrightnessAdjustment;
private final float pressedBrightnessAdjustment;
@JsonCreator
public Theme(
@JsonProperty("name") final String name,
@JsonProperty("patternRendererName") final String patternRendererName,
@JsonProperty("borderOuter") final HSBColor borderOuter,
@JsonProperty("borderInner") final HSBColor borderInner,
@JsonProperty("borderInnerDisabled") final HSBColor borderInnerDisabled,
@JsonProperty("backgroundNormal") final HSBColor backgroundNormal,
@JsonProperty("backgroundSelected") final HSBColor backgroundSelected,
@JsonProperty("textNormal") final HSBColor textNormal,
@JsonProperty("textDisabled") final HSBColor textDisabled,
@JsonProperty("selectionPrimary") final HSBColor selectionPrimary,
@JsonProperty("selectionSecondary") final HSBColor selectionSecondary,
@JsonProperty("windowBackground") final HSBColor windowBackground,
@JsonProperty("hoverBrightnessAdjustment") final float hoverBrightnessAdjustment,
@JsonProperty("pressedBrightnessAdjustment") final float pressedBrightnessAdjustment) {
this.name = name;
this.patternRendererName = patternRendererName;
this.borderOuter = borderOuter;
this.borderInner = borderInner;
this.borderInnerDisabled = borderInnerDisabled;
this.backgroundNormal = backgroundNormal;
this.backgroundSelected = backgroundSelected;
this.textNormal = textNormal;
this.textDisabled = textDisabled;
this.selectionPrimary = selectionPrimary;
this.selectionSecondary = selectionSecondary;
this.windowBackground = windowBackground;
this.hoverBrightnessAdjustment = hoverBrightnessAdjustment;
this.pressedBrightnessAdjustment = pressedBrightnessAdjustment;
}
private Theme(final Builder builder) {
this.name = builder.name;
this.patternRendererName = builder.patternRendererName;
this.borderOuter = builder.borderOuter;
this.borderInner = builder.borderInner;
this.borderInnerDisabled = builder.borderInnerDisabled;
this.backgroundNormal = builder.backgroundNormal;
this.backgroundSelected = builder.backgroundSelected;
this.textNormal = builder.textNormal;
this.textDisabled = builder.textDisabled;
this.selectionPrimary = builder.selectionPrimary;
this.selectionSecondary = builder.selectionSecondary;
this.windowBackground = builder.windowBackground;
this.hoverBrightnessAdjustment = builder.hoverBrightnessAdjustment;
this.pressedBrightnessAdjustment = builder.pressedBrightnessAdjustment;
this(
builder.name,
builder.patternRendererName,
builder.borderOuter,
builder.borderInner,
builder.borderInnerDisabled,
builder.backgroundNormal,
builder.backgroundSelected,
builder.textNormal,
builder.textDisabled,
builder.selectionPrimary,
builder.selectionSecondary,
builder.windowBackground,
builder.hoverBrightnessAdjustment,
builder.pressedBrightnessAdjustment);
}
public String getName() {