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,13 +1,21 @@
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();
void setCurrentTheme(Theme theme);

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() {

View File

@@ -26,7 +26,6 @@ import de.extio.game_engine.renderer.model.event.UiControlEvent;
import de.extio.game_engine.renderer.work.RenderingBoPool;
import de.extio.game_engine.resource.StaticResource;
import de.extio.game_engine.spatial2.model.ImmutableCoordI2;
import de.extio.game_engine.storage.StorageService;
public class DemoModule extends AbstractClientModule {
@@ -48,9 +47,6 @@ public class DemoModule extends AbstractClientModule {
@Autowired
private ThemeManager themeManager;
@Autowired
private StorageService storageService;
private Window mainWindow;
private Window secondaryWindow;
@@ -93,12 +89,6 @@ public class DemoModule extends AbstractClientModule {
@Override
public void onActivate() {
this.storageService.loadByPath(String.class, List.of("gameEngine"), "theme").ifPresent(themeName -> {
if (themeName != null && !themeName.isBlank()) {
this.themeManager.setCurrentTheme(themeName);
}
});
final var audioOptions = this.audioController.getAudioOptions();
this.audioMuted = isAudioMuted(audioOptions);
@@ -158,7 +148,6 @@ public class DemoModule extends AbstractClientModule {
if (themeName != null) {
this.audioController.play(new StaticResource(List.of("audio"), "alert0.ogg"));
this.themeManager.setCurrentTheme(themeName);
this.storageService.store(List.of("gameEngine"), "theme", themeName);
}
}
}

View File

@@ -0,0 +1,222 @@
package de.extio.game_engine.renderer.g2d.theme;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import de.extio.game_engine.resource.StaticResource;
import de.extio.game_engine.resource.StaticResourceService;
import de.extio.game_engine.storage.StorageService;
public class G2DThemeManagerTest {
private static Theme theme(final String name) {
return Theme.builder()
.name(name)
.patternRendererName("testPatternRenderer")
.borderOuter(new HSBColor(0.1f, 0.2f, 0.3f))
.borderInner(new HSBColor(0.1f, 0.2f, 0.3f))
.borderInnerDisabled(new HSBColor(0.1f, 0.2f, 0.3f))
.backgroundNormal(new HSBColor(0.1f, 0.2f, 0.3f))
.backgroundSelected(new HSBColor(0.1f, 0.2f, 0.3f))
.textNormal(new HSBColor(0.1f, 0.2f, 0.3f))
.textDisabled(new HSBColor(0.1f, 0.2f, 0.3f))
.selectionPrimary(new HSBColor(0.1f, 0.2f, 0.3f))
.selectionSecondary(new HSBColor(0.1f, 0.2f, 0.3f))
.windowBackground(new HSBColor(0.1f, 0.2f, 0.3f))
.hoverBrightnessAdjustment(0.25f)
.pressedBrightnessAdjustment(0.40f)
.build();
}
@Test
void loadsLastUsedThemeFromStorageOnConstruction() {
final StaticResourceService staticResourceService = org.mockito.Mockito.mock(StaticResourceService.class);
final StorageService storageService = org.mockito.Mockito.mock(StorageService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
final Theme defaultTheme = theme("Default");
final Theme persistedTheme = theme("Persisted");
when(storageService.loadByPath(eq(Theme.class), eq(List.of("gameEngine", "themes")), eq("lastTheme")))
.thenReturn(Optional.of(persistedTheme));
final var manager = new G2DThemeManager(
staticResourceService,
storageService,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
assertEquals("Persisted", manager.getCurrentTheme().getName());
assertTrue(manager.getAvailableThemeNames().contains("Persisted"));
}
@Test
void setCurrentThemePersistsLastThemeObject() {
final StaticResourceService staticResourceService = org.mockito.Mockito.mock(StaticResourceService.class);
final StorageService storageService = org.mockito.Mockito.mock(StorageService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
when(storageService.loadByPath(eq(Theme.class), eq(List.of("gameEngine", "themes")), eq("lastTheme")))
.thenReturn(Optional.empty());
final Theme defaultTheme = theme("Default");
final var manager = new G2DThemeManager(
staticResourceService,
storageService,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
final Theme selected = theme("Selected");
manager.setCurrentTheme(selected);
verify(storageService).store(eq(List.of("gameEngine", "themes")), eq("lastTheme"), eq(selected));
assertTrue(manager.getAvailableThemeNames().contains("Selected"));
}
@Test
void loadThemeFromStorageRegistersTheme() {
final StaticResourceService staticResourceService = org.mockito.Mockito.mock(StaticResourceService.class);
final StorageService storageService = org.mockito.Mockito.mock(StorageService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
when(storageService.loadByPath(eq(Theme.class), eq(List.of("gameEngine", "themes")), eq("lastTheme")))
.thenReturn(Optional.empty());
final Theme defaultTheme = theme("Default");
final var manager = new G2DThemeManager(
staticResourceService,
storageService,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
final Theme loaded = theme("FromStorage");
when(storageService.loadByPath(eq(Theme.class), eq(List.of("gameEngine", "themes")), eq("FromStorage")))
.thenReturn(Optional.of(loaded));
final Optional<Theme> result = manager.loadThemeFromStorage("FromStorage");
assertTrue(result.isPresent());
assertEquals("FromStorage", result.get().getName());
assertTrue(manager.getAvailableThemeNames().contains("FromStorage"));
}
@Test
void loadThemeFromStaticResourceRegistersTheme() {
final StaticResourceService staticResourceService = org.mockito.Mockito.mock(StaticResourceService.class);
final StorageService storageService = org.mockito.Mockito.mock(StorageService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
when(storageService.loadByPath(eq(Theme.class), eq(List.of("gameEngine", "themes")), eq("lastTheme")))
.thenReturn(Optional.empty());
final Theme defaultTheme = theme("Default");
final var manager = new G2DThemeManager(
staticResourceService,
storageService,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
final Theme loaded = theme("FromStaticResource");
final StaticResource resource = new StaticResource(List.of("themes"), "fromStatic.theme");
when(staticResourceService.loadByPath(eq(Theme.class), eq(resource)))
.thenReturn(Optional.of(loaded));
final Optional<Theme> result = manager.loadThemeFromStaticResource(resource);
assertTrue(result.isPresent());
assertEquals("FromStaticResource", result.get().getName());
assertTrue(manager.getAvailableThemeNames().contains("FromStaticResource"));
}
@Test
void saveThemeToStorageUsesThemeNameAsStorageName() {
final StaticResourceService staticResourceService = org.mockito.Mockito.mock(StaticResourceService.class);
final StorageService storageService = org.mockito.Mockito.mock(StorageService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
when(storageService.loadByPath(eq(Theme.class), eq(List.of("gameEngine", "themes")), eq("lastTheme")))
.thenReturn(Optional.empty());
final Theme defaultTheme = theme("Default");
final var manager = new G2DThemeManager(
staticResourceService,
storageService,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
final Theme themeToSave = theme("UserTheme");
manager.saveThemeToStorage(themeToSave);
verify(storageService).store(eq(List.of("gameEngine", "themes")), eq("UserTheme"), eq(themeToSave));
}
@Test
void saveThemeToStorageThrowsIfNoStorageService() {
final StaticResourceService staticResourceService = org.mockito.Mockito.mock(StaticResourceService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
final Theme defaultTheme = theme("Default");
final var manager = new G2DThemeManager(
staticResourceService,
null,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
assertThrows(IllegalStateException.class, () -> manager.saveThemeToStorage(theme("SomeTheme")));
}
@Test
void loadThemeFromStorageReturnsEmptyWhenStorageNotAvailable() {
final StaticResourceService staticResourceService = org.mockito.Mockito.mock(StaticResourceService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
final Theme defaultTheme = theme("Default");
final var manager = new G2DThemeManager(
staticResourceService,
null,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
final Optional<Theme> loaded = manager.loadThemeFromStorage("Anything");
assertFalse(loaded.isPresent());
}
@Test
void loadThemeFromStaticResourceReturnsEmptyWhenStaticResourceServiceNotAvailable() {
final StorageService storageService = org.mockito.Mockito.mock(StorageService.class);
final PatternRenderer patternRenderer = org.mockito.Mockito.mock(PatternRenderer.class);
when(storageService.loadByPath(eq(Theme.class), eq(List.of("gameEngine", "themes")), eq("lastTheme")))
.thenReturn(Optional.empty());
final Theme defaultTheme = theme("Default");
final var manager = new G2DThemeManager(
null,
storageService,
List.of(patternRenderer),
Map.of("defaultKey", defaultTheme),
"defaultKey");
final Optional<Theme> loaded = manager.loadThemeFromStaticResource(new StaticResource(List.of("themes"), "x"));
assertFalse(loaded.isPresent());
verify(storageService, never()).store(any(), any(), any());
}
}