diff --git a/docs/frontend-refresh/README.md b/docs/frontend-refresh/README.md index e92a7bb73..06c137468 100644 --- a/docs/frontend-refresh/README.md +++ b/docs/frontend-refresh/README.md @@ -2,9 +2,11 @@ The opening menus should feel like an invitation into Glob2's living world. This proposal keeps the game's colorful colony art, gives it a real AI-driven life -behind the menus, and replaces the old textured widget frames with a restrained -sage-and-gold presentation. The main action is prominent; secondary actions are -quieter. This is a visual refresh of the existing screens, not a replacement UI +behind the menus, and replaces the old textured widget frames with inked gel +controls on a warm, translucent membrane, drawn from one palette +(`FrontendPalette`) in the game's own hues. The main action is prominent; +secondary actions are quieter. See `docs/ui-visual-review/` for the review that +set this direction and the before/after captures. This is a visual refresh of the existing screens, not a replacement UI or rendering engine. ## Design and coverage @@ -30,6 +32,10 @@ panel calculation. There is no new layout system, shader, rendering backend, worker thread, or network session. `MenuColony` owns a separate `Game`, camera state, fixed-step accumulator and RNG. +On OpenGL its camera drifts about a tile and a half on a slow Lissajous, and the +theme composites one cloud shadow+layer pass over the whole window after every +widget has painted (`Style::afterPaint`), in place of the pass `drawMap` would +make under the panel (`DRAW_NO_CLOUDS`). Both are presentation state only. It polls Econo, executes its order and advances the normal simulation at 25 ticks/second. Each update runs at most two steps, dropping excessive lag. Loading, stepping and drawing temporarily exchange the legacy global RNG and diff --git a/docs/ui-visual-review/README.md b/docs/ui-visual-review/README.md new file mode 100644 index 000000000..d7c77d7ce --- /dev/null +++ b/docs/ui-visual-review/README.md @@ -0,0 +1,135 @@ +# Front-end visual revamp + +This directory holds the review that motivated the revamp and the captures that +show its result. The review's measurements stand as written; the "spike" it +described has been replaced by the changes summarised below. + +Captures come from the real client renderer via `MenuColonyHarness capture`, +at 1152x720. `before-*.png` is the front end as it shipped after #202, #236 and +#237; `after-*.png` is this branch on the software backend; `after-gl-*.png` is +this branch on OpenGL, where cloud shadows, the camera drift and sub-tile +motion exist. + +## What was wrong + +The refreshed front end read as sterile because every channel that could carry +character was off at once: + +- **Colour.** The chrome ran 11–38% saturation over a world at 56–75%, in a + gray-cream nothing in the game uses, with a cream veil desaturating the + colony so the pale interface could sit on it. +- **Line.** Every game sprite and every concept drawing in + `datasrc/gfx/concept-art/` is a heavy, slightly wobbly ink contour. The + interface separated surfaces by 4%-lightness steps and a 1 px hairline. +- **Life.** The "live colony" had a fixed camera, its cloud shadows never + reached the panel, and the panel was 97% opaque: a wallpaper. +- **Structure.** Nine near-cream fills across five drifted palette copies, and + three parallel drawing layers (theme hooks, `LobbyControls`, + `SettingsScreenLayout`) — a full rewrite of the theme left the Settings + panel pixel-identical. + +## What changed + +Six commits, each verified with the harness gates and captures. + +1. **One palette.** `FrontendPalette` in `src/FrontendTheme.h`: ink, muted, + gold, violet (the water's hue, for keyboard focus), membrane, gel, scrim. + Every front-end literal routes through it. +2. **Inked gel on a membrane.** `FrontendTheme::blob()` draws a rounded + contour with per-corner radii and a gentle, rect-seeded wobble as a 2 px ink + ring around a fill with a gel highlight; `ring()` is the outline alone, for + frames drawn over list contents. Theme hooks, the main menu, the lobby's pad + helpers and the settings screen all draw through it. Panels are a warm, + translucent membrane the colony tints through (opaque under low-speed + graphics). Widget geometry and hit rects are unchanged. +3. **The world acts on the UI.** A `Style::afterPaint` hook runs after every + widget has painted; the theme uses it for one cloud shadow+layer pass over + the whole window, anchored to the colony's viewport, replacing the pass + `drawMap` used to make under the panel (`DRAW_NO_CLOUDS`). The colony camera + drifts about a tile and a half on a slow Lissajous. Both OpenGL-only by the + engine's existing guards; both presentation-only, outside the simulation. +4. **Jelly.** Buttons swell up to 2 px under the cursor and squash 1 px while + pressed; the front page fades in over its first ten frames. +5. **A glob on the front page.** The colony's worker sprite, in its team + colour, walks the band under the utility buttons. The classic sheet's faint + matte, invisible on grass, is thresholded off once at composite time. + +## Backend notes + +- Software: everything except cloud shadows over the UI, camera drift and the + entry fade's sub-tile motion. The membrane, ink, jelly and glob all render. +- OpenGL: all of it. Cloud strength over the sheet follows the same + `cloudMaxAlpha` setting as the world; if a maintainer finds it too strong on + the large Settings sheet, that is one constant in `FrontendTheme::afterPaint`. +- Modal dialogs composite over a frozen snapshot of the parent and refill + opaquely each frame (`OverlayScreen::executeModal`), so they stay opaque by + design. + +## Follow-up: smoother motion and cleaner edges + +Playtesting the built client surfaced three concrete rendering bugs behind an +overall "feels a bit 90s" reaction, none of them requiring a style change to fix: + +- **Button swell had only 2-3 states.** The hover inflate was computed as + `int(hi)*2/255` — integer division on a smooth 0-255 highlight collapsed it + to whole-pixel jumps instead of a continuous grow. `FrontendTheme::swell()` + now draws the base size opaque and crossfades a one-pixel-larger outline in + over the fractional part, so the swell reads as continuous. Used by the main + menu buttons and the shared theme's button background. +- **Menu-colony camera drift was jarring.** The drift was floored to a whole + pixel before being split into tile + sub-tile fraction, so the camera sat + still for seconds near the drift's turning points and then hopped a pixel. + `MenuColony::draw()` now keeps the drift a `double` all the way to the GL + transform, matching how the in-game camera's own fraction is already a + float (`TorusView::draw`) for the same reason. +- **Contour edges read as pixel art, with notches at the corners.** `blob()` + and `ring()` rounded every corner and wobble offset to a whole pixel with no + anti-aliasing, and the wobble was applied inside the rounded-corner curves + themselves, chipping them. The contour now carries fractional insets + through to the boundary pixel (blended by coverage) and fades the wobble to + zero inside the corner curves. + +None of this changes layout, hit rects or the simulation; re-verified with the +same harness gates listed below, plus a fresh `run-game-speed-tests.py` pass +(checksum `8b9c6da6`, unchanged from the original captures). + +## Follow-up: green over cream, and less see-through + +Further playtesting pushed back on the cream/sand chrome itself ("still feels +a bit 90s") and, separately, on the front page reading as too transparent: + +- `FrontendPalette::membrane`/`gel`/`gelDisabled` are now a green tint instead + of sand, at HSL hue ~120 — sampled off the actual rendered terrain (not + picked by eye) and rotated to the cream tokens' existing saturation/lightness + before being taken up to a more saturated tier (`membrane` settled at + `(168,230,168)`, one step back from an over-saturated `(103,228,103)` that + read as too much). `ink`, `gold` and `violet` are unchanged. +- The front page's membrane alpha, deliberately lowered to 140 earlier in this + branch so the colony would read through, went too far the other way — it's + now 210 (out of 255), and the shared `panelAlpha()` default used by Settings + and custom-game moved from 214 to 235. + +Re-verified the same way as the smoothing follow-up above; checksum unchanged. + +## Verification + +Run from the repository root, `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy` +unless noted: + +```sh +scons -j8 release=0 server=0 build/src/glob2 menu-colony-harness custom-setup-test speed-tests +build/src/MenuColonyHarness check data/menu/colony.bin # presentation, text bounds, checksums, routes +build/src/MenuColonyHarness navigation unused # enter/cancel 14 real screen loops +build/src/MenuColonyHarness sessions unused # play/quit, replay, results, editor +build/src/CustomGameSetupHarness # lobby model +xvfb-run -a build/src/CustomGameSetupHarness artifacts/ci # lobby visual (needs an X server) +xvfb-run -a python3 test/run-game-speed-tests.py # sim checksums unchanged (needs a window) +``` + +All pass on this branch. `MenuColonyHarness` previews now invoke the style's +`afterPaint`, mirroring `Screen::dispatchPaint`, so captures show what the game +shows; a 120-frame OpenGL `record` measured the world shifting (−9, −4) px over +80 frames and the panel band's luminance moving 198→204 as a shadow crossed it. + +Per `AGENTS.md`, a maintainer playing this in motion is part of review. The +captures are evidence, not a substitute. diff --git a/docs/ui-visual-review/after-campaign.png b/docs/ui-visual-review/after-campaign.png new file mode 100644 index 000000000..d8c58c57c Binary files /dev/null and b/docs/ui-visual-review/after-campaign.png differ diff --git a/docs/ui-visual-review/after-credits.png b/docs/ui-visual-review/after-credits.png new file mode 100644 index 000000000..592c048a9 Binary files /dev/null and b/docs/ui-visual-review/after-credits.png differ diff --git a/docs/ui-visual-review/after-custom-players.png b/docs/ui-visual-review/after-custom-players.png new file mode 100644 index 000000000..3a3472cbe Binary files /dev/null and b/docs/ui-visual-review/after-custom-players.png differ diff --git a/docs/ui-visual-review/after-custom-rules.png b/docs/ui-visual-review/after-custom-rules.png new file mode 100644 index 000000000..85f8db8be Binary files /dev/null and b/docs/ui-visual-review/after-custom-rules.png differ diff --git a/docs/ui-visual-review/after-custom.png b/docs/ui-visual-review/after-custom.png new file mode 100644 index 000000000..f8ccb52a4 Binary files /dev/null and b/docs/ui-visual-review/after-custom.png differ diff --git a/docs/ui-visual-review/after-gl-main.png b/docs/ui-visual-review/after-gl-main.png new file mode 100644 index 000000000..1e3daf246 Binary files /dev/null and b/docs/ui-visual-review/after-gl-main.png differ diff --git a/docs/ui-visual-review/after-gl-settings.png b/docs/ui-visual-review/after-gl-settings.png new file mode 100644 index 000000000..8485fb4f6 Binary files /dev/null and b/docs/ui-visual-review/after-gl-settings.png differ diff --git a/docs/ui-visual-review/after-load.png b/docs/ui-visual-review/after-load.png new file mode 100644 index 000000000..cbd4e71fb Binary files /dev/null and b/docs/ui-visual-review/after-load.png differ diff --git a/docs/ui-visual-review/after-main.png b/docs/ui-visual-review/after-main.png new file mode 100644 index 000000000..16cf097f8 Binary files /dev/null and b/docs/ui-visual-review/after-main.png differ diff --git a/docs/ui-visual-review/after-results.png b/docs/ui-visual-review/after-results.png new file mode 100644 index 000000000..9f25fc729 Binary files /dev/null and b/docs/ui-visual-review/after-results.png differ diff --git a/docs/ui-visual-review/after-settings.png b/docs/ui-visual-review/after-settings.png new file mode 100644 index 000000000..9fb8cbb8b Binary files /dev/null and b/docs/ui-visual-review/after-settings.png differ diff --git a/docs/ui-visual-review/before-custom.png b/docs/ui-visual-review/before-custom.png new file mode 100644 index 000000000..87773a4df Binary files /dev/null and b/docs/ui-visual-review/before-custom.png differ diff --git a/docs/ui-visual-review/before-main.png b/docs/ui-visual-review/before-main.png new file mode 100644 index 000000000..482827188 Binary files /dev/null and b/docs/ui-visual-review/before-main.png differ diff --git a/docs/ui-visual-review/before-results.png b/docs/ui-visual-review/before-results.png new file mode 100644 index 000000000..bdd35c07c Binary files /dev/null and b/docs/ui-visual-review/before-results.png differ diff --git a/docs/ui-visual-review/before-settings.png b/docs/ui-visual-review/before-settings.png new file mode 100644 index 000000000..b8b3de588 Binary files /dev/null and b/docs/ui-visual-review/before-settings.png differ diff --git a/libgag/include/GUIStyle.h b/libgag/include/GUIStyle.h index 8a79a43d9..b95b252d3 100644 --- a/libgag/include/GUIStyle.h +++ b/libgag/include/GUIStyle.h @@ -36,6 +36,8 @@ namespace GAGGUI virtual ~Style() { } // Optional presentation tick; default styles have no background work. virtual void onFrame() { } + // Optional pass over the finished frame, after every widget has painted. + virtual void afterPaint(GAGCore::DrawableSurface*) { } virtual void drawButtonSelection(GAGCore::DrawableSurface*, int, int, int, int) { } virtual bool usesThemeTextColor() const { return false; } virtual void drawFieldBackground(GAGCore::DrawableSurface*, int, int, int, int) { } diff --git a/libgag/src/GUIBase.cpp b/libgag/src/GUIBase.cpp index 5d17c6f01..1fbaea0d6 100644 --- a/libgag/src/GUIBase.cpp +++ b/libgag/src/GUIBase.cpp @@ -641,6 +641,7 @@ namespace GAGGUI if ((*it)->visible) (*it)->paint(); } + Style::style->afterPaint(gfx); /* We need a second loop in order to have tooltip over everything else */ for (std::set::iterator it=widgets.begin(); it!=widgets.end(); ++it) { diff --git a/src/CustomGameScreen.cpp b/src/CustomGameScreen.cpp index 99e839b17..61e783b0e 100644 --- a/src/CustomGameScreen.cpp +++ b/src/CustomGameScreen.cpp @@ -48,7 +48,9 @@ CustomGameChoiceScreen::CustomGameChoiceScreen(const std::string &title, auto &ui = *controls; int w = std::min(gfx->getW() - 32, 1000), x = (gfx->getW() - w) / 2, height = gfx->getH(); ui.setDimensions(gfx->getW(), height); - ui.box({x - 8, 8, w + 16, height - 16}, Color(232, 237, 218), 8); + FrontendTheme::rounded(ui.surface(), x - 5, 13, w + 16, height - 16, 12, Color(12, 28, 16, 70)); + FrontendTheme::blob(ui.surface(), x - 8, 8, w + 16, height - 16, 12, + Color(FrontendPalette::membrane.r, FrontendPalette::membrane.g, FrontendPalette::membrane.b, FrontendTheme::panelAlpha()), ui.ink, 2); ui.text(x + 8, 20, this->title, "standard", w - 16); int left = w < 800 ? 174 : 235, right = x + left + 22, rightW = w - left - 22; ui.beginRegion(20, {x, 60, left, height - 135}); @@ -558,12 +560,32 @@ void CustomGameScreen::showAIProfile(int colony) setup.colonies[colony].ai = (AI::ImplementationID)AINames::selectionOrder()[result]; } +// Both lobby screens draw their own page membrane sized to the window, so the +// theme paints only the world behind them (the widget-union panel would be a +// second, smaller sheet underneath). +void CustomGameChoiceScreen::paint() +{ + if (FrontendTheme::current && GAGGUI::Style::style == FrontendTheme::current) + FrontendTheme::current->background(gfx, false); + else + Glob2Screen::paint(); +} +void CustomGameScreen::paint() +{ + if (FrontendTheme::current && GAGGUI::Style::style == FrontendTheme::current) + FrontendTheme::current->background(gfx, false); + else + Glob2TabScreen::paint(); +} + void CustomGameScreen::renderLobby() { auto &ui = *controls; int width = gfx->getW(), height = gfx->getH(); int w = std::min(width - 32, 1120), x = (width - w) / 2; - ui.box({x - 8, 8, w + 16, height - 16}, Color(232, 237, 218), 8); + FrontendTheme::rounded(ui.surface(), x - 5, 13, w + 16, height - 16, 12, Color(12, 28, 16, 70)); + FrontendTheme::blob(ui.surface(), x - 8, 8, w + 16, height - 16, 12, + Color(FrontendPalette::membrane.r, FrontendPalette::membrane.g, FrontendPalette::membrane.b, FrontendTheme::panelAlpha()), ui.ink, 2); auto speed = globalContainer->settings; speed.gameSpeed = setup.speed; std::vector titles = localized({"Map", "Players & Teams", "Game Rules"}); @@ -595,7 +617,7 @@ void CustomGameScreen::renderLobby() else renderMap(x, bodyY, w, bodyH); gfx->setClipRect(); - gfx->drawLine(x, height - 85, x + w, height - 85, ui.line); + gfx->drawFilledRect(x, height - 85, w, 1, Color(ui.ink.r, ui.ink.g, ui.ink.b, 70)); std::string error = setup.validation(); if (!setup.random && !validMap) error = tr("Select a valid map."); @@ -641,7 +663,7 @@ void CustomGameScreen::renderPlayers(int x, int y, int w, int h) int ry = top + i * rowH - offset; ui.box({x, ry, w - 12, rowH - 8}, ui.panel); Color color = - i < int(preview->starts.size()) ? preview->starts[i].color : Color(160, 172, 149); + i < int(preview->starts.size()) ? preview->starts[i].color : FrontendPalette::gelDisabled; ui.box({x + 10, ry + 10, 30, 30}, color, 4); gfx->drawRect(x + 10, ry + 10, 30, 30, ui.ink); auto font = Toolkit::getFont("standard"); @@ -1031,7 +1053,7 @@ void CustomGameScreen::renderMap(int x, int y, int w, int h) rightW, true); int size = std::min(rightW, h - 126); int px = rightX + (rightW - size) / 2, py = top + 51; - ui.box({px - 3, py - 3, size + 6, size + 6}, ui.line); + FrontendTheme::ring(gfx, px - 3, py - 3, size + 6, size + 6, 4, ui.ink, 0); if (validMap) { preview->setScreenPosition(px - (gfx->getW() - 640) / 2, py - (gfx->getH() - 480) / 2); @@ -1040,7 +1062,7 @@ void CustomGameScreen::renderMap(int x, int y, int w, int h) } else { - ui.box({px, py, size, size}, Color(211, 223, 197)); + ui.box({px, py, size, size}, FrontendPalette::gelDisabled); ui.paragraph(px + 16, py + size / 2 - 20, size - 32, tr(previewPending ? "Updating map preview..." diff --git a/src/CustomGameScreen.h b/src/CustomGameScreen.h index 8158bebdc..4551e6f1c 100644 --- a/src/CustomGameScreen.h +++ b/src/CustomGameScreen.h @@ -21,6 +21,7 @@ class CustomGameChoiceScreen : public Glob2Screen const std::vector &); void onSDLEvent(SDL_Event *) override; void onAction(Widget *, Action, int, int) override; + void paint() override; }; class LobbyMapPreview; class LobbyControls; @@ -38,6 +39,7 @@ class CustomGameScreen : public Glob2TabScreen void onGroupActivated(int) override; void onSDLEvent(SDL_Event *) override; void onTimer(Uint32 tick) override; + void paint() override; void updateLayout() override; MapHeader &getMapHeader() { return mapHeader; } GameHeader &getGameHeader(); diff --git a/src/FrontendTheme.cpp b/src/FrontendTheme.cpp index db43cf89f..9cd77743c 100644 --- a/src/FrontendTheme.cpp +++ b/src/FrontendTheme.cpp @@ -1,13 +1,16 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "FrontendTheme.h" #include "MenuColony.h" +#include "DynamicClouds.h" #include "GlobalContainer.h" #include #include #include +#include using namespace GAGCore; using namespace GAGGUI; +using namespace FrontendPalette; FrontendTheme* FrontendTheme::current = nullptr; bool FrontendTheme::allowed = true; namespace { const char* fontNames[] = {"menu", "standard", "little"}; } @@ -16,11 +19,11 @@ FrontendTheme::FrontendTheme() : original(Style::style) { current = this; colony = std::make_unique(); - textColor = Color(36,69,49); - highlightColor = Color(67,116,75); - frameColor = Color(137,160,132); - listSelectedElementColor = Color(227,192,119); - backColor = backOverlayColor = Color(230,231,210); + textColor = ink; + highlightColor = Color(74,110,58); + frameColor = ink; + listSelectedElementColor = gold; + backColor = backOverlayColor = membrane; for (int i=0;i<3;++i) originalFonts[i] = Toolkit::getFont(fontNames[i])->getStyle(); fallback = std::make_unique(1,1); if (!fallback->loadImage("data/gfx/menu-colony.png")) fallback.reset(); @@ -63,6 +66,123 @@ void FrontendTheme::rounded(DrawableSurface* s,int x,int y,int w,int h,int r,Col s->drawFilledRect(x+inset,y+h-row-1,w-2*inset,1,c); } } +namespace +{ +// Per-row [left inset, right inset] of a blob contour, kept fractional so the +// caller can anti-alias the boundary instead of snapping it to a whole pixel. +// Corners get slightly unequal radii and the vertical edges a gentle sine +// wobble, both derived from the rect so the same control always draws the +// same outline. The wobble fades out inside the corner curves themselves +// (where the corner inset is already large) so it ripples the straight edges +// without chipping notches out of the rounded corners. +struct Contour +{ + std::vector> inset; + Contour(int x,int y,int w,int h,int r,int wobble,int inflate) + { + const unsigned seed = unsigned(x)*73856093u ^ unsigned(y)*19349663u ^ unsigned(w)*83492791u ^ unsigned(h)*2654435761u; + x-=inflate; y-=inflate; w+=2*inflate; h+=2*inflate; + r = std::min({r,w/2,h/2}); + const int jitter = r>=6 ? 1 : 0; + const int rTL=r+((seed>>0&1)?jitter:-jitter), rTR=r+((seed>>1&1)?jitter:-jitter); + const int rBL=r+((seed>>2&1)?jitter:-jitter), rBR=r+((seed>>3&1)?jitter:-jitter); + const float phaseL=(seed>>4&255)/40.0f, phaseR=(seed>>12&255)/40.0f; + inset.resize(std::max(0,h)); + auto corner=[](int radius,int row)->float + { + if(row>=radius) return 0.0f; + const float dy=radius-row-0.5f; + return radius-std::sqrt(std::max(0.0f,float(radius*radius)-dy*dy)); + }; + for(int row=0;row0.004f) s->drawFilledRect(edgePx,py,1,1,faded(c,1.0f-frac)); + return i+(frac>0.004f?1:0); +} +} + +void FrontendTheme::blob(DrawableSurface* s,int x,int y,int w,int h,int r,Color fill,Color ink,int wobble,int inflate) +{ + if(w<=0 || h<=0) return; + const Contour c(x,y,w,h,r,wobble,inflate); + x-=inflate; y-=inflate; w+=2*inflate; h+=2*inflate; + // Ink and fill never overlap, so a translucent fill shows what is behind it. + for(int row=0;row=h-2) { s->drawFilledRect(x+lFull,y+row,span,1,ink); continue; } + s->drawFilledRect(x+lFull,y+row,std::min(2,span),1,ink); + if(span>2) s->drawFilledRect(x+w-rFull-std::min(2,span-2),y+row,std::min(2,span-2),1,ink); + if(span>4) s->drawFilledRect(x+lFull+2,y+row,span-4,1,row==2 ? lighter(fill,10) : fill); + } +} +void FrontendTheme::ring(DrawableSurface* s,int x,int y,int w,int h,int r,Color ink,int wobble,int inflate) +{ + if(w<=0 || h<=0) return; + const Contour c(x,y,w,h,r,wobble,inflate); + x-=inflate; y-=inflate; w+=2*inflate; h+=2*inflate; + for(int row=0;row=h-2) { if(span>0) s->drawFilledRect(x+lFull,y+row,span,1,ink); continue; } + s->drawFilledRect(x+lFull,y+row,std::min(2,span),1,ink); + if(span>2) s->drawFilledRect(x+w-rFull-std::min(2,span-2),y+row,std::min(2,span-2),1,ink); + } +} +// A blob whose inflate can be fractional: the base size draws opaque, and a +// second, one-pixel-larger blob crossfades in over it at `inflate`'s +// fractional part. Hover/press swelling reads as a continuous grow instead of +// popping between the whole-pixel sizes blob() alone can draw. +void FrontendTheme::swell(DrawableSurface* s,int x,int y,int w,int h,int r,Color fill,Color ink,int wobble,float inflate) +{ + const int base=int(std::floor(inflate)); + const float frac=inflate-base; + blob(s,x,y,w,h,r,fill,ink,wobble,base); + if(frac>0.004f) blob(s,x,y,w,h,r,faded(fill,frac),faded(ink,frac),wobble,base+1); +} +int FrontendTheme::panelAlpha(int normal) +{ + return (globalContainer->settings.optionFlags & GlobalContainer::OPTION_LOW_SPEED_GFX) ? 255 : normal; +} void FrontendTheme::onFrame() { if (!painted) return; // Present the still before doing any loading work. @@ -71,6 +191,15 @@ void FrontendTheme::onFrame() const bool visible = window && !(SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED); colony->update(SDL_GetTicks64(), visible); } +// Cloud shadows cross the panel and its controls: the interface is a thing in +// the world, not a window over it. The engine's own guard keeps this GL-only. +void FrontendTheme::afterPaint(DrawableSurface* s) +{ + if (s != globalContainer->gfx || Style::style != this || !colony->ready()) return; + if (globalContainer->settings.optionFlags & GlobalContainer::OPTION_LOW_SPEED_GFX) return; + if (!clouds) clouds = std::make_unique(&globalContainer->settings); + colony->drawClouds(*clouds, s->getW(), s->getH()); +} void FrontendTheme::background(DrawableSurface* s, bool panel, const SDL_Rect* content) { const int w=s->getW(),h=s->getH(); @@ -92,35 +221,39 @@ void FrontendTheme::background(DrawableSurface* s, bool panel, const SDL_Rect* c } if(fittedFallback) s->drawSurface(0,0,fittedFallback.get()); } - s->drawFilledRect(0,0,w,h,Color(232,237,218,42)); + s->drawFilledRect(0,0,w,h,Color(scrim.r,scrim.g,scrim.b,44)); if (panel) { const SDL_Rect area=content ? *content : SDL_Rect{(w-640)/2,(h-480)/2,640,480}; const int x=std::max(0,area.x-12), y=std::max(0,area.y-12); const int pw=std::min(w,area.x+area.w+12)-x, ph=std::min(h,area.y+area.h+12)-y; - rounded(s,x+2,y+3,pw,ph,10,Color(15,39,25,35)); - rounded(s,x,y,pw,ph,10,Color(230,231,210,248)); + rounded(s,x+3,y+5,pw,ph,12,Color(12,28,16,70)); + blob(s,x,y,pw,ph,12,Color(membrane.r,membrane.g,membrane.b,panelAlpha()),ink,2); } painted=true; } void FrontendTheme::drawTextButtonBackground(DrawableSurface* s,int x,int y,int w,int h,unsigned hi) { - rounded(s,x,y,w,h,4,Color(234,240,228)); - if(hi) rounded(s,x,y,w,h,4,Color(169,196,157,hi/2)); + // Swells up to two pixels under the cursor; the hit rect is unchanged. + swell(s,x,y,w,h,5,gel,ink,1,hi*2.0f/255.0f); + if(hi) rounded(s,x+2,y+2,w-4,h-4,3,Color(gold.r,gold.g,gold.b,hi/2)); } void FrontendTheme::drawFrame(DrawableSurface* s,int x,int y,int w,int h,unsigned hi) { // Frames are also drawn AFTER list contents; never erase their interior. - s->drawRect(x,y,w,h,hi ? highlightColor : frameColor); + ring(s,x,y,w,h,5,hi ? highlightColor : frameColor,w>=20 && h>=20 ? 1 : 0); } void FrontendTheme::drawOnOffButton(DrawableSurface* s,int x,int y,int w,int h,unsigned hi,bool state) { - drawTextButtonBackground(s,x,y,w,h,hi); - drawFrame(s,x,y,w,h,hi); + blob(s,x,y,w,h,4,state ? gold : gel,ink,w>=20 && h>=20 ? 1 : 0); + if(hi) rounded(s,x+2,y+2,w-4,h-4,3,Color(gold.r,gold.g,gold.b,hi/2)); if(state) { - s->drawLine(x+w/5,y+h/2,x+w*2/5,y+h*3/4,textColor); - s->drawLine(x+w*2/5,y+h*3/4,x+w*4/5,y+h/4,textColor); + for(int t=0;t<2;++t) + { + s->drawLine(x+w/5,y+h/2+t,x+w*2/5,y+h*3/4+t,ink); + s->drawLine(x+w*2/5,y+h*3/4+t,x+w*4/5,y+h/4+t,ink); + } } } void FrontendTheme::drawTriButton(DrawableSurface* s,int x,int y,int w,int h,unsigned hi,Uint8 state) @@ -132,8 +265,8 @@ void FrontendTheme::drawScrollBar(DrawableSurface* s,int x,int y,int,int h,int p { const int width=getStyleMetric(STYLE_METRIC_LIST_SCROLLBAR_WIDTH); const int end=getStyleMetric(STYLE_METRIC_LIST_SCROLLBAR_TOP_WIDTH); - rounded(s,x,y,width,h,4,Color(202,218,196)); - rounded(s,x+3,y+end+pos,width-6,len,3,frameColor); + blob(s,x,y,width,h,4,gelDisabled,ink,0); + rounded(s,x+3,y+end+pos,width-6,len,3,muted); for(int i=0;i<4;++i) { s->drawLine(x+width/2-i,y+end/2+i,x+width/2+i,y+end/2+i,textColor); @@ -143,8 +276,8 @@ void FrontendTheme::drawScrollBar(DrawableSurface* s,int x,int y,int,int h,int p void FrontendTheme::drawProgressBar(DrawableSurface* s,int x,int y,int w,int value,int range) { const int h=getStyleMetric(STYLE_METRIC_PROGRESS_BAR_HEIGHT); - rounded(s,x,y,w,h,4,Color(202,218,196)); - if(range>0) rounded(s,x,y,int(w*double(std::clamp(value,0,range))/range),h,4,listSelectedElementColor); + blob(s,x,y,w,h,4,gelDisabled,ink,0); + if(range>0) rounded(s,x+2,y+2,std::max(0,int((w-4)*double(std::clamp(value,0,range))/range)),h-4,3,gold); } int FrontendTheme::getStyleMetric(StyleMetrics m) { @@ -154,7 +287,7 @@ int FrontendTheme::getStyleMetric(StyleMetrics m) void FrontendTheme::drawFieldBackground(DrawableSurface* s,int x,int y,int w,int h) { - rounded(s,x,y,w,h,4,Color(234,240,228)); + blob(s,x,y,w,h,5,gel,ink,w>=20 && h>=20 ? 1 : 0); } void FrontendTheme::drawSelectionBackground(DrawableSurface* s,int x,int y,int w,int h) { @@ -162,13 +295,14 @@ void FrontendTheme::drawSelectionBackground(DrawableSurface* s,int x,int y,int w } bool FrontendTheme::drawSelector(DrawableSurface* s,int x,int y,int w,int h,unsigned value,unsigned maximum) { - rounded(s,x,y+h/2,w,4,2,frameColor); - const int position=maximum ? int((w-6)*double(value)/maximum) : 0; - rounded(s,x+position,y,6,h+4,3,highlightColor); + rounded(s,x,y+h/2-1,w,6,3,ink); + rounded(s,x+1,y+h/2,w-2,4,2,gelDisabled); + const int position=maximum ? int((w-12)*double(value)/maximum) : 0; + blob(s,x+position,y-2,12,h+8,5,gold,ink,0); return true; } void FrontendTheme::drawButtonSelection(DrawableSurface* s,int x,int y,int w,int h) { - rounded(s,x,y,w,h,4,Color(227,192,119)); + rounded(s,x+2,y+2,w-4,h-4,3,gold); } diff --git a/src/FrontendTheme.h b/src/FrontendTheme.h index 4a11b11f2..6a6daaa3b 100644 --- a/src/FrontendTheme.h +++ b/src/FrontendTheme.h @@ -4,6 +4,26 @@ #include #include class MenuColony; +class DynamicClouds; + +// The front end's whole palette. Every menu screen reads from here; alpha is +// applied at the draw site, never baked into a token. +namespace FrontendPalette +{ + inline const GAGCore::Color ink(20, 44, 28); // text, contours, hover ring + inline const GAGCore::Color muted(76, 123, 76); // secondary text + inline const GAGCore::Color gold(233, 176, 53); // primary action, selection + inline const GAGCore::Color goldPressed(206, 152, 40); + inline const GAGCore::Color violet(92, 74, 198); // keyboard focus ring (the water's hue) + // Membrane/gel hue is the terrain's own grass green (sampled off the + // rendered map, ~HSL 120/52/25) tinted up to panel/control lightness, + // rather than the sand tint: a cream chrome over a green world was the + // original complaint, and rotating hue keeps the same tonal structure. + inline const GAGCore::Color membrane(168, 230, 168); // panel fill; tints toward moss over the world + inline const GAGCore::Color gel(219, 240, 219); // opaque control fill + inline const GAGCore::Color gelDisabled(203, 226, 203); + inline const GAGCore::Color scrim(18, 34, 22); // veil / modal dim +} class FrontendTheme : public GAGGUI::Style { @@ -13,8 +33,24 @@ class FrontendTheme : public GAGGUI::Style static FrontendTheme* current; static bool allowed; static void rounded(GAGCore::DrawableSurface*, int, int, int, int, int, GAGCore::Color); + // Hand-drawn contour: per-corner radii and a low-frequency edge wobble seeded + // from the rect, so it is stable frame to frame. Ink ring, fill, gel highlight. + // inflate grows the drawn shape without changing the seed or the caller's rect. + static void blob(GAGCore::DrawableSurface*, int x, int y, int w, int h, int r, + GAGCore::Color fill, GAGCore::Color ink, int wobble, int inflate = 0); + // The ring of blob() alone; never touches the interior (safe over list contents). + static void ring(GAGCore::DrawableSurface*, int x, int y, int w, int h, int r, + GAGCore::Color ink, int wobble, int inflate = 0); + // blob(), but inflate may be fractional: the extra pixel of growth + // crossfades in instead of popping, so continuous hover/press animation + // reads as a smooth swell rather than 2-3 discrete sizes. + static void swell(GAGCore::DrawableSurface*, int x, int y, int w, int h, int r, + GAGCore::Color fill, GAGCore::Color ink, int wobble, float inflate); + // Membrane opacity: 'normal' for the usual case; 255 under low-speed graphics. + static int panelAlpha(int normal = 235); void background(GAGCore::DrawableSurface*, bool panel = true, const SDL_Rect* content = nullptr); void onFrame() override; + void afterPaint(GAGCore::DrawableSurface*) override; void drawButtonSelection(GAGCore::DrawableSurface*, int, int, int, int) override; bool usesThemeTextColor() const override { return true; } void drawFieldBackground(GAGCore::DrawableSurface*, int, int, int, int) override; @@ -33,6 +69,7 @@ class FrontendTheme : public GAGGUI::Style private: bool attempted = false, painted = false; std::unique_ptr fallback, fittedFallback; + std::unique_ptr clouds; }; // Also used to suspend front-end presentation around gameplay/editor loops. diff --git a/src/Game.h b/src/Game.h index 8eced3411..e39628476 100644 --- a/src/Game.h +++ b/src/Game.h @@ -167,6 +167,8 @@ class Game DRAW_NO_RESOURCE_GROWTH_AREAS = 0x80, DRAW_OVERLAY = 0x100, DRAW_NO_CLOUD_LAYER = 0x200, + /// Skip the cloud shadow and layer entirely; the caller composites its own pass. + DRAW_NO_CLOUDS = 0x400, }; /// This method will prepare the game with the provided gameHeader, diff --git a/src/LobbyControls.h b/src/LobbyControls.h index 781dc05f3..58a3fec41 100644 --- a/src/LobbyControls.h +++ b/src/LobbyControls.h @@ -33,8 +33,8 @@ class LobbyControls : public GAGGUI::RectangularWidget std::map regions; std::string focus, pressed; int activeRegion = -1, hoverX = 0, hoverY = 0; - GAGCore::Color ink{36, 69, 49}, muted{89, 108, 86}, panel{243, 245, 233}, gold{227, 192, 119}, - line{194, 207, 183}; + GAGCore::Color ink = FrontendPalette::ink, muted = FrontendPalette::muted, panel = FrontendPalette::gel, + gold = FrontendPalette::gold, line = FrontendPalette::ink; struct Popup { bool open = false; @@ -65,6 +65,11 @@ class LobbyControls : public GAGGUI::RectangularWidget { FrontendTheme::rounded(surface(), r.x, r.y, r.w, r.h, radius, color); } + // An inked gel pad: every clickable thing in the lobby. + void pad(SDL_Rect r, GAGCore::Color fill, int radius = 5, int wobble = 1, int inflate = 0) + { + FrontendTheme::blob(surface(), r.x, r.y, r.w, r.h, radius, fill, ink, wobble, inflate); + } void text(int x, int y, std::string value, const char *font = "standard", int width = 10000, bool quiet = false) { @@ -123,13 +128,22 @@ class LobbyControls : public GAGGUI::RectangularWidget { SDL_Rect clipping = clip(); hits.push_back({id, r, clipping, std::move(action), enabled, activeRegion}); + const bool hovered = !popup.open && inside(r, hoverX, hoverY) && inside(clipping, hoverX, hoverY); + if (focus == id) + box({r.x - 3, r.y - 3, r.w + 6, r.h + 6}, FrontendPalette::violet, 7); + // Rows inside a scrolling list stay quiet until chosen; standalone + // buttons are inked gel pads. + const bool listRow = activeRegion >= 0; if (selected) - box(r, gold); + pad(r, gold); + else if (!quiet && listRow) + box(r, enabled ? panel : FrontendPalette::gelDisabled, 4); else if (!quiet) - box(r, enabled ? panel : GAGCore::Color(222, 226, 212)); - if (focus == id || - (!popup.open && inside(r, hoverX, hoverY) && inside(clipping, hoverX, hoverY))) - surface()->drawRect(r.x, r.y, r.w, r.h, focus == id ? ink : line); + pad(r, enabled ? panel : FrontendPalette::gelDisabled, 5, 1, hovered && enabled ? 1 : 0); + if ((quiet || listRow) && !selected && (hovered || focus == id)) + FrontendTheme::ring(surface(), r.x, r.y, r.w, r.h, 5, ink, listRow ? 0 : 1); + if (hovered && !selected && !quiet && enabled) + box({r.x + 2, r.y + 2, r.w - 4, r.h - 4}, GAGCore::Color(gold.r, gold.g, gold.b, 110), 3); int fh = GAGCore::Toolkit::getFont(font)->getStringHeight("Ag"); text(r.x + 9, r.y + (r.h - fh) / 2, label, font, r.w - 18, !enabled || quiet); } @@ -144,7 +158,9 @@ class LobbyControls : public GAGGUI::RectangularWidget popup = {true, r, options, enabled, apply, std::max(0, selected), 0, help}; revealPopup(); }); - surface()->drawRect(r.x, r.y, r.w, r.h, line); + // A choice is always an inked pad, even among quiet list rows. + if (activeRegion >= 0) + FrontendTheme::ring(surface(), r.x, r.y, r.w, r.h, 5, ink, 1); // The right edge is reserved for the disclosure chevron. box({r.x + r.w - 18, r.y + 4, 14, r.h - 8}, panel, 2); text(r.x + r.w - 15, r.y + (r.h - 12) / 2, "v", "little", 12, true); @@ -177,10 +193,11 @@ class LobbyControls : public GAGGUI::RectangularWidget button(id, r, "", [this, change] { change(hoverX); }, false, true, true); sliders[id] = change; sliderKeys[id] = [=](int delta) { apply(std::clamp(value + delta, lo, hi)); }; - box({r.x, r.y + r.h / 2 - 2, r.w, 4}, line, 2); + box({r.x, r.y + r.h / 2 - 3, r.w, 6}, ink, 3); + box({r.x + 1, r.y + r.h / 2 - 2, r.w - 2, 4}, FrontendPalette::gelDisabled, 2); int pos = (value - lo) * (r.w - 12) / std::max(1, hi - lo); - box({r.x, r.y + r.h / 2 - 2, pos + 6, 4}, muted, 2); - box({r.x + pos, r.y + 3, 12, r.h - 6}, gold, 4); + box({r.x + 1, r.y + r.h / 2 - 2, pos + 5, 4}, gold, 2); + pad({r.x + pos, r.y + 2, 12, r.h - 4}, gold, 5, 0); } void beginRegion(int id, SDL_Rect r) { @@ -199,7 +216,7 @@ class LobbyControls : public GAGGUI::RectangularWidget if (reg.maximum > 0) { auto b = reg.box; - box({b.x + b.w - 5, b.y, 4, b.h}, line, 2); + box({b.x + b.w - 5, b.y, 4, b.h}, GAGCore::Color(ink.r, ink.g, ink.b, 40), 2); int thumb = std::max(24, b.h * b.h / (b.h + reg.maximum)); box({b.x + b.w - 5, b.y + (b.h - thumb) * reg.offset / reg.maximum, 4, thumb}, muted, 2); @@ -247,9 +264,8 @@ class LobbyControls : public GAGGUI::RectangularWidget if (popup.open) { auto r = popupRect(); - box({r.x + 3, r.y + 4, r.w, r.h}, GAGCore::Color(34, 54, 36, 100)); - box(r, panel); - surface()->drawRect(r.x, r.y, r.w, r.h, muted); + box({r.x + 3, r.y + 4, r.w, r.h}, GAGCore::Color(FrontendPalette::scrim.r, FrontendPalette::scrim.g, FrontendPalette::scrim.b, 100)); + pad(r, panel, 6, 2); for (int n = 0; n < 8 && n + popup.first < int(popup.options.size()); ++n) { int i = n + popup.first; @@ -261,7 +277,7 @@ class LobbyControls : public GAGGUI::RectangularWidget if (popup.options.size() > 8) { int thumb = 240 * 8 / int(popup.options.size()); - box({r.x + r.w - 5, r.y + 6, 3, 240}, line, 1); + box({r.x + r.w - 5, r.y + 6, 3, 240}, GAGCore::Color(ink.r, ink.g, ink.b, 40), 1); box({r.x + r.w - 5, r.y + 6 + (240 - thumb) * popup.first / (int(popup.options.size()) - 8), 3, thumb}, diff --git a/src/MainMenuScreen.cpp b/src/MainMenuScreen.cpp index 048849c2c..52a7111f4 100644 --- a/src/MainMenuScreen.cpp +++ b/src/MainMenuScreen.cpp @@ -4,6 +4,8 @@ #include "MainMenuScreen.h" #include "FrontendTheme.h" #include "GlobalContainer.h" +#include "MenuColony.h" +#include "render/UnitSkin.h" #include #include #include @@ -25,12 +27,10 @@ using namespace GAGCore; namespace { -const Color gold(227, 192, 119); -const Color ink(17, 35, 32); -const Color textColor(36, 69, 49); -const Color muted(92, 114, 91); - +using namespace FrontendPalette; const auto fillRounded = FrontendTheme::rounded; +// The front page still lets the colony read through, but less than before. +const int frontPageAlpha = 210; } // Front-page layout and primary-action emphasis; shared drawing comes from the theme. @@ -53,21 +53,25 @@ class MainMenuButton : public TextButton auto* target = parent->getSurface(); const unsigned hover = focused ? 255 : getNextHighlightValue(); const int radius = primary ? 8 : 4; - if (focused) fillRounded(target, x - 3, y - 3, w + 6, h + 6, radius + 3, Color(67, 116, 75)); + // Jelly: swell under the cursor, squash while pressed; fade in with the panel. + const float inflate = pressed ? -1.0f : hover * 2.0f / 255.0f; + const Uint8 entry = Uint8(255 * std::min(10, parent->animationFrame) / 10); + auto withAlpha = [entry](Color c) { return Color(c.r, c.g, c.b, Uint8(int(c.a) * entry / 255)); }; + if (focused) fillRounded(target, x - 4, y - 4, w + 8, h + 8, radius + 4, withAlpha(violet)); if (primary) { - fillRounded(target, x, y, w, h, radius, pressed ? Color(208, 172, 99) : gold); - if (hover) fillRounded(target, x, y, w, h, radius, Color(255, 245, 211, hover / 5)); + FrontendTheme::swell(target, x, y, w, h, radius, withAlpha(pressed ? goldPressed : gold), withAlpha(ink), 1, inflate); + if (hover) fillRounded(target, x + 2, y + 2, w - 4, h - 4, radius - 1, Color(255, 235, 170, hover / 4)); } else { - fillRounded(target, x, y, w, h, radius, Color(234, 240, 228)); - if (hover) fillRounded(target, x, y, w, h, radius, Color(169, 196, 157, hover / 2)); - if (pressed) fillRounded(target, x, y, w, h, radius, Color(92, 130, 71, 55)); + FrontendTheme::swell(target, x, y, w, h, radius, withAlpha(gel), withAlpha(ink), 1, inflate); + if (hover) fillRounded(target, x + 2, y + 2, w - 4, h - 4, radius - 1, Color(gold.r, gold.g, gold.b, hover / 2)); + if (pressed) fillRounded(target, x + 2, y + 2, w - 4, h - 4, radius - 1, Color(180, 140, 50, 70)); } Font* labelFont=fontPtr; if(labelFont->getStringWidth(text)>w-(primary?44:28)) labelFont=Toolkit::getFont("front-small"); - labelFont->pushStyle(Font::Style(Font::STYLE_NORMAL, primary ? ink : textColor)); + labelFont->pushStyle(Font::Style(Font::STYLE_NORMAL, withAlpha(ink))); const int textY = y + (h - labelFont->getStringHeight(text)) / 2; int cx, cy, cw, ch; target->getClipRect(&cx, &cy, &cw, &ch); @@ -138,7 +142,7 @@ MainMenuScreen::MainMenuScreen() double coverage = goldInk ? (int(green) - int(blue) - 20) / 65.0 : (232 - int(red)) / 200.0; coverage = coverage < 0.03 ? 0.0 : std::min(1.0, coverage); pixels[col] = SDL_MapRGBA(fitted->format, - goldInk ? 227 : 36, goldInk ? 192 : 69, goldInk ? 119 : 49, + goldInk ? gold.r : ink.r, goldInk ? gold.g : ink.g, goldInk ? gold.b : ink.b, static_cast(std::lround(255 * coverage))); } } @@ -151,6 +155,7 @@ MainMenuScreen::MainMenuScreen() const int x = panelX + 24, w = panelW - 48; + glob.x = x + 8; int y = panelY + (compact ? 74 : 110); auto add = [&](const char* label, int action, int h, const char* font, bool primary = false) { @@ -193,19 +198,85 @@ MainMenuScreen::~MainMenuScreen() void MainMenuScreen::paint() { if (FrontendTheme::current) FrontendTheme::current->background(gfx, false); - fillRounded(gfx, panelX + 2, panelY + 3, panelW, panelH, 10, Color(15, 39, 25, 35)); - fillRounded(gfx, panelX, panelY, panelW, panelH, 10, Color(230, 231, 210, 248)); - fillRounded(gfx, panelX + 24, panelY + 12, 36, 4, 2, gold); - if (wordmark) gfx->drawSurface(panelX + 24, panelY + 24, wordmark.get()); + // The front page fades in over the screen's first ten frames. + const int entry = std::min(10, animationFrame); + auto withAlpha = [entry](Color c) { return Color(c.r, c.g, c.b, Uint8(int(c.a) * entry / 10)); }; + fillRounded(gfx, panelX + 3, panelY + 5, panelW, panelH, 12, withAlpha(Color(12, 28, 16, 70))); + FrontendTheme::blob(gfx, panelX, panelY, panelW, panelH, 12, + withAlpha(Color(membrane.r, membrane.g, membrane.b, FrontendTheme::panelAlpha(frontPageAlpha))), withAlpha(ink), 2); + fillRounded(gfx, panelX + 24, panelY + 12, 36, 4, 2, withAlpha(gold)); + if (wordmark) gfx->drawSurface(panelX + 24, panelY + 24, wordmark.get(), Uint8(255 * entry / 10)); else { auto* title = Toolkit::getFont("front-title"); - title->setStyle(Font::Style(Font::STYLE_NORMAL, textColor)); + title->setStyle(Font::Style(Font::STYLE_NORMAL, ink)); gfx->drawString(panelX + 24, panelY + 24, title, "Globulation 2"); } auto* caption = Toolkit::getFont("front-caption"); caption->setStyle(Font::Style(Font::STYLE_NORMAL, muted)); gfx->drawString(panelX + 24, panelY + panelH - 30, caption, PACKAGE_VERSION); + + // The glob: the colony's own worker sprite, in its team colour, walking the + // otherwise empty band above the version caption. Direction 3 faces east, + // 7 west; eight walk frames per direction. + if (globFrames.empty() && FrontendTheme::current && FrontendTheme::current->colony->ready()) buildGlobFrames(); + if (!globFrames.empty()) + { + const int frame = glob.walking ? int(glob.phase / 80) % 8 : 0; + auto* image = globFrames[(glob.dir > 0 ? 0 : 8) + frame].get(); + const int gx = int(glob.x) - (image->getW() - 32) / 2, gy = panelY + panelH - 92 - (image->getH() - 32) / 2; + gfx->drawSurface(gx, gy, image, Uint8(255 * entry / 10)); + } +} + +void MainMenuScreen::buildGlobFrames() +{ + const auto& skin = g_unitSkins[WORKER]; + if (!skin.sprite) return; + skin.sprite->setBaseColor(FrontendTheme::current->colony->teamColor()); + for (int direction : {3, 7}) + for (int frame = 0; frame < 8; ++frame) + { + const int index = int(skin.startImage[WALK]) + 8 * direction + frame; + const int w = skin.sprite->getW(index), h = skin.sprite->getH(index); + DrawableSurface canvas(w, h); + SDL_Surface* pixels = canvas.getSDLSurface(); + SDL_FillRect(pixels, nullptr, 0); + canvas.drawSprite(0, 0, skin.sprite, index); + for (int row = 0; row < h; ++row) + { + auto* line = reinterpret_cast(static_cast(pixels->pixels) + row * pixels->pitch); + for (int col = 0; col < w; ++col) + { + Uint8 red, green, blue, alpha; + SDL_GetRGBA(line[col], pixels->format, &red, &green, &blue, &alpha); + if (alpha < 32) line[col] = SDL_MapRGBA(pixels->format, 0, 0, 0, 0); + } + } + SDL_SetSurfaceBlendMode(pixels, SDL_BLENDMODE_BLEND); + globFrames.push_back(std::make_unique(pixels)); + } +} + +void MainMenuScreen::onTimer(Uint32 tick) +{ + if (glob.lastTick == 0) { glob.lastTick = tick; return; } + const Uint32 elapsed = std::min(tick - glob.lastTick, 100); + glob.lastTick = tick; + glob.walking = tick >= glob.idleUntil; + if (!glob.walking) return; + const int left = panelX + 32, right = panelX + panelW - 64; + glob.x += glob.dir * 24.0 * elapsed / 1000.0; + glob.phase += elapsed; + glob.seed = glob.seed * 1664525u + 1013904223u; + const bool atEdge = glob.x <= left || glob.x >= right; + // Turn at the edges; otherwise pause now and then, about once every six seconds. + if (atEdge || (glob.seed >> 8) % 6000 < elapsed) + { + glob.x = std::clamp(glob.x, double(left), double(right)); + if (atEdge) glob.dir = -glob.dir; + glob.idleUntil = tick + 1000 + (glob.seed >> 16) % 2000; + } } void MainMenuScreen::onSDLEvent(SDL_Event* event) diff --git a/src/MainMenuScreen.h b/src/MainMenuScreen.h index 0b31a65ef..87b6064b6 100644 --- a/src/MainMenuScreen.h +++ b/src/MainMenuScreen.h @@ -33,10 +33,24 @@ class MainMenuScreen:public Glob2Screen static int menu(void); void paint(void) override; void onSDLEvent(SDL_Event *event) override; + void onTimer(Uint32 tick) override; private: std::vector buttons; std::unique_ptr wordmark; int focusedButton = -1; int panelX, panelY, panelW, panelH; + // A worker from the colony wandering the strip under the buttons. + struct MenuGlob + { + double x = 0; + int dir = 1; + bool walking = true; + Uint32 phase = 0, lastTick = 0, idleUntil = 0; + unsigned seed = 0x9e3779b9u; + } glob; + // Sixteen walk frames (east, west) composited once with the sheet's faint + // matte removed: it never showed on grass, but does on a pale membrane. + std::vector> globFrames; + void buildGlobFrames(); }; diff --git a/src/MenuColony.cpp b/src/MenuColony.cpp index d9c5f23b0..c2bb6a60c 100644 --- a/src/MenuColony.cpp +++ b/src/MenuColony.cpp @@ -6,6 +6,8 @@ #include "DatasetWriter.h" #include "Order.h" #include "Player.h" +#include "DynamicClouds.h" +#include #include #include #include @@ -79,7 +81,9 @@ void MenuColony::update(Uint64 now, bool visible) { if (!game || !visible) { pause(); return; } if (!clockStarted) { lastTime = now; clockStarted = true; return; } - pending += std::min(now - lastTime, 2 * GAME_TICK_MS); + const Uint64 elapsed = std::min(now - lastTime, 2 * GAME_TICK_MS); + pending += elapsed; + driftClock += elapsed / 1000.0; lastTime = now; ColonyContext context(rng); // At most two steps per menu frame; never accumulate hidden-time debt. @@ -114,10 +118,51 @@ void MenuColony::draw(int width, int height) globalContainer->replayVisibleTeams=teams; } } viewContext; - // Place the starting settlement to the right of the front-page panel. - const int x = (centerX - width * 2 / 3 / 32) & game->map.getMaskW(); - const int y = (centerY - height / 2 / 32) & game->map.getMaskH(); - game->drawMap(0, 0, width, height, 0, 0, x, y, 0, view, Game::DRAW_WHOLE_MAP | Game::DRAW_HEALTH_FOOD_BAR); + // Place the starting settlement to the right of the front-page panel, then + // let the camera wander about a tile and a half around it over ~1.5 minutes. + // Kept as a double throughout: flooring to a pixel before splitting into + // tile+fraction quantized the whole path to whole-pixel steps, so the + // camera would sit still for seconds near the drift's turning points and + // then hop a pixel — the in-game camera's own fraction is a float for the + // same reason (see TorusView::draw). + double driftX = 0, driftY = 0; + if (smoothCamera()) + { + driftX = 48.0 * std::sin(driftClock * 2.0 * M_PI / 97.0); + driftY = 32.0 * std::sin(driftClock * 2.0 * M_PI / 131.0); + } + const int tileX = int(std::floor(driftX / 32.0)), tileY = int(std::floor(driftY / 32.0)); + fractionX = float(driftX - tileX * 32.0); + fractionY = float(driftY - tileY * 32.0); + viewX = (centerX - width * 2 / 3 / 32 + tileX) & game->map.getMaskW(); + viewY = (centerY - height / 2 / 32 + tileY) & game->map.getMaskH(); + auto* gfx = globalContainer->gfx; + gfx->beginMapTransform(1.0f, -fractionX, -fractionY, 0, 0, width, height); + game->drawMap(0, 0, width + 32, height + 32, 0, 0, viewX, viewY, 0, view, + Game::DRAW_WHOLE_MAP | Game::DRAW_HEALTH_FOOD_BAR | Game::DRAW_NO_CLOUDS); + gfx->endMapTransform(); +} + +bool MenuColony::smoothCamera() const +{ + return (globalContainer->gfx->getOptionFlags() & GAGCore::GraphicContext::USEGPU) != 0; +} + +void MenuColony::drawClouds(DynamicClouds& clouds, int width, int height) +{ + if (!game) return; + auto* gfx = globalContainer->gfx; + gfx->beginMapTransform(1.0f, float(-fractionX), float(-fractionY), 0, 0, width, height); + clouds.compute(viewX, viewY, width + 32, height + 32, cloudTime, game->map.getW(), game->map.getH()); + clouds.render(gfx, width + 32, height + 32, DynamicClouds::SHADOW); + clouds.render(gfx, width + 32, height + 32, DynamicClouds::CLOUD); + gfx->endMapTransform(); + if (clockStarted) ++cloudTime; +} + +GAGCore::Color MenuColony::teamColor() const +{ + return game ? game->teams[0]->color : GAGCore::Color(64, 200, 160); } Uint32 MenuColony::checksum() const diff --git a/src/MenuColony.h b/src/MenuColony.h index ca373fcd4..811b25732 100644 --- a/src/MenuColony.h +++ b/src/MenuColony.h @@ -4,6 +4,7 @@ #include "Utilities.h" #include #include +class DynamicClouds; // A local decorative game. Never owns input, audio, networking or replay sinks. class MenuColony @@ -13,7 +14,11 @@ class MenuColony void update(Uint64 now, bool visible = true); void pause(); void draw(int width, int height); + // One cloud shadow+layer pass over the whole window, aligned with the last + // draw() so it can be composited above the interface. + void drawClouds(DynamicClouds& clouds, int width, int height); bool ready() const { return bool(game); } + GAGCore::Color teamColor() const; Uint32 tick() const { return game ? game->stepCounter : 0; } Uint32 checksum() const; private: @@ -23,4 +28,11 @@ class MenuColony Uint64 lastTime = 0, pending = 0; bool clockStarted = false; int centerX = 0, centerY = 0; + // Slow camera drift (OpenGL only: the software path has no sub-tile blit). + double driftClock = 0; + int viewX = 0, viewY = 0, cloudTime = 0; + // Sub-tile camera offset: float so drift moves continuously instead of + // snapping to whole pixels (see draw()). + float fractionX = 0, fractionY = 0; + bool smoothCamera() const; }; diff --git a/src/SettingsScreen.h b/src/SettingsScreen.h index 3ddf8f3a0..4ecd1b957 100644 --- a/src/SettingsScreen.h +++ b/src/SettingsScreen.h @@ -97,6 +97,9 @@ class SettingsScreen : public Glob2Screen void layout(); void paintRow(const Row& row); void drawText(int x,int y,const std::string& text, bool muted=false, bool heading=false); + void fill(const Rect& r,GAGCore::Color color,int radius=4); + void control(const Rect& r,GAGCore::Color fill,bool focused,int radius=5); + void rule(int x,int y,int w); int wrappedHeight(const std::string& text,int width,bool heading=false) const; void drawWrapped(int x,int y,int width,const std::string& text,bool muted=false,bool heading=false); std::vector wrap(const std::string& text,int width,bool heading=false) const; diff --git a/src/SettingsScreenLayout.cpp b/src/SettingsScreenLayout.cpp index 2137870e4..553c8f743 100644 --- a/src/SettingsScreenLayout.cpp +++ b/src/SettingsScreenLayout.cpp @@ -1,12 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "SettingsScreen.h" #include "GlobalContainer.h" +#include "FrontendTheme.h" #include #include using namespace GAGCore; namespace { -const Color ink(41,70,51),muted(83,101,76),paper(240,241,223),rail(225,231,209),line(190,203,176),field(249,250,240),gold(228,199,121); +const Color ink=FrontendPalette::ink,muted=FrontendPalette::muted,paper=FrontendPalette::membrane, + rail=FrontendPalette::gelDisabled,line=FrontendPalette::ink,field=FrontendPalette::gel,gold=FrontendPalette::gold; } std::vector SettingsScreen::wrap(const std::string& text,int width,bool heading) const @@ -39,6 +41,19 @@ void SettingsScreen::drawText(int x,int y,const std::string& text,bool secondary font->pushStyle(Font::Style(Font::STYLE_NORMAL,color.r,color.g,color.b)); gfx->drawString(x,y,font,text);font->popStyle(); } +void SettingsScreen::fill(const Rect& r,Color color,int radius) +{ + FrontendTheme::rounded(gfx,r.x,r.y,r.w,r.h,radius,color); +} +void SettingsScreen::control(const Rect& r,Color color,bool focused,int radius) +{ + if(focused)FrontendTheme::rounded(gfx,r.x-3,r.y-3,r.w+6,r.h+6,radius+2,FrontendPalette::violet); + FrontendTheme::blob(gfx,r.x,r.y,r.w,r.h,radius,color,ink,r.w>=20&&r.h>=20?1:0); +} +void SettingsScreen::rule(int x,int y,int w) +{ + gfx->drawFilledRect(x,y,w,1,Color(ink.r,ink.g,ink.b,60)); +} void SettingsScreen::drawWrapped(int x,int y,int width,const std::string& text,bool secondary,bool heading) { const int height=Toolkit::getFont(heading?"menu":"standard")->getStringHeight("Ag")+4; @@ -118,34 +133,33 @@ void SettingsScreen::paintRow(const Row& r) if(r.kind==Kind::Info){drawWrapped(b.x,b.y+(r.columns>1?std::max(0,(b.h-wrappedHeight(r.label,b.w))/2):0),b.w,r.label,true);return;} bool focused=focus==r.id; if(r.kind==Kind::Button){ - gfx->drawFilledRect(c.x,c.y,c.w,c.h,r.selected?gold:field); - gfx->drawRect(c.x,c.y,c.w,c.h,focused?ink:line); + control(c,r.selected?gold:field,focused); drawWrapped(c.x+12,c.y+10,c.w-24,r.label,!r.enabled);return; } int textW=c.x>b.x?c.x-b.x-24:b.w; drawWrapped(b.x,b.y+12,textW,r.label,!r.enabled); if(!r.help.empty())drawWrapped(b.x,b.y+12+wrappedHeight(r.label,textW)+8,textW,r.help,true); if(r.kind==Kind::Toggle){ - gfx->drawFilledRect(c.x,c.y,c.w,c.h,r.enabled?field:rail); - gfx->drawRect(c.x,c.y,c.w,c.h,focused?ink:line); - gfx->drawRect(c.x+10,c.y+(c.h-18)/2,18,18,ink); - if(r.number){gfx->drawLine(c.x+13,c.y+c.h/2,c.x+17,c.y+c.h/2+5,ink);gfx->drawLine(c.x+17,c.y+c.h/2+5,c.x+25,c.y+c.h/2-6,ink);} + control(c,r.enabled?field:rail,focused); + FrontendTheme::blob(gfx,c.x+10,c.y+(c.h-18)/2,18,18,4,r.number?gold:field,ink,0); + if(r.number)for(int t=0;t<2;++t){gfx->drawLine(c.x+13,c.y+c.h/2+t,c.x+17,c.y+c.h/2+5+t,ink);gfx->drawLine(c.x+17,c.y+c.h/2+5+t,c.x+25,c.y+c.h/2-6+t,ink);} drawText(c.x+38,c.y+8,tr(r.number?"On":"Off")); }else if(r.kind==Kind::Slider){ drawText(c.x,c.y,r.value,!r.enabled); const int sy=c.y+c.h-10; - gfx->drawFilledRect(c.x,sy,c.w,4,line); - int dx=(c.w-8)*r.number/std::max(1,r.maximum); - gfx->drawFilledRect(c.x+dx,sy-5,8,14,r.enabled?ink:line); - if(focused)gfx->drawRect(c.x-3,c.y-3,c.w+6,c.h+6,ink); + fill({c.x,sy-1,c.w,6},ink,3); + fill({c.x+1,sy,c.w-2,4},rail,2); + int dx=(c.w-12)*r.number/std::max(1,r.maximum); + fill({c.x+1,sy,dx+5,4},gold,2); + if(focused)FrontendTheme::ring(gfx,c.x-4,c.y-4,c.w+8,c.h+8,6,FrontendPalette::violet,0); + FrontendTheme::blob(gfx,c.x+dx,sy-6,12,16,5,r.enabled?gold:rail,ink,0); }else{ - gfx->drawFilledRect(c.x,c.y,c.w,c.h,r.enabled?field:rail); - gfx->drawRect(c.x,c.y,c.w,c.h,focused?ink:line); + control(c,r.enabled?field:rail,focused); if(r.kind==Kind::Number){ const int segment=std::min(32,c.w/4); drawText(c.x+8,c.y+8,"−");drawText(c.x+c.w-20,c.y+8,"+"); - gfx->drawLine(c.x+segment,c.y,c.x+segment,c.y+c.h,line); - gfx->drawLine(c.x+c.w-segment,c.y,c.x+c.w-segment,c.y+c.h,line); + gfx->drawFilledRect(c.x+segment,c.y+2,1,c.h-4,muted); + gfx->drawFilledRect(c.x+c.w-segment,c.y+2,1,c.h-4,muted); int tw=Toolkit::getFont("standard")->getStringWidth(r.value); drawText(c.x+(c.w-tw)/2,c.y+8,r.value); }else{ @@ -160,39 +174,41 @@ void SettingsScreen::paintRow(const Row& r) gfx->drawLine(c.x+c.w-15,c.y+c.h/2+3,c.x+c.w-10,c.y+c.h/2-2,ink); } if(!r.extraId.empty()){ - gfx->drawRect(c.x+c.w-36,c.y,36,c.h,focus==r.extraId?ink:line); + gfx->drawFilledRect(c.x+c.w-36,c.y+2,1,c.h-4,muted); + if(focus==r.extraId)FrontendTheme::ring(gfx,c.x+c.w-38,c.y-2,40,c.h+4,5,FrontendPalette::violet,0); drawText(c.x+c.w-24,c.y+8,"+"); } gfx->setClipRect(viewport.x,viewport.y,viewport.w,viewport.h); } } - gfx->drawLine(b.x,b.y+b.h-1,b.x+b.w,b.y+b.h-1,line); + rule(b.x,b.y+b.h-1,b.w); } void SettingsScreen::paint() { layout();buildRows();layout(); - // Opaque settings-local surfaces keep background texture out of the form. - Glob2Screen::paint(); - gfx->drawFilledRect(0,0,getW(),getH(),Color(20,32,22,200)); - gfx->drawFilledRect(panel.x,panel.y,panel.w,panel.h,paper); - gfx->drawRect(panel.x,panel.y,panel.w,panel.h,line); + // The theme paints the world and its shared scrim only (its widget-union + // panel would be a second sheet underneath); the settings sheet is the same + // membrane as every other screen, so the colony reads through it here too. + if(FrontendTheme::current && GAGGUI::Style::style==FrontendTheme::current)FrontendTheme::current->background(gfx,false); + else Glob2Screen::paint(); + FrontendTheme::rounded(gfx,panel.x+3,panel.y+5,panel.w,panel.h,12,Color(12,28,16,70)); + FrontendTheme::blob(gfx,panel.x,panel.y,panel.w,panel.h,12,Color(paper.r,paper.g,paper.b,FrontendTheme::panelAlpha()),ink,2); drawText(panel.x+padding,panel.y+20,tr("Settings"),false,true); const char* categories[]={"Display & graphics","Audio","Gameplay","Building defaults","Controls","Language & player"}; if(modal==Modal::None && compactNavigation){ const auto& c=categoryControl; - gfx->drawFilledRect(c.x,c.y,c.w,c.h,field); - gfx->drawRect(c.x,c.y,c.w,c.h,focus=="nav.current"?ink:line); + control(c,field,focus=="nav.current"); drawWrapped(c.x+10,c.y+8,c.w-36,tr(categories[int(current)])); gfx->drawLine(c.x+c.w-20,c.y+c.h/2-2,c.x+c.w-15,c.y+c.h/2+3,ink); gfx->drawLine(c.x+c.w-15,c.y+c.h/2+3,c.x+c.w-10,c.y+c.h/2-2,ink); } if(modal==Modal::None && !compactNavigation){ - gfx->drawFilledRect(panel.x+1,panel.y+64,sidebar-1,footer.y-panel.y-64,rail); + gfx->drawFilledRect(panel.x+2,panel.y+64,sidebar-2,footer.y-panel.y-66,Color(ink.r,ink.g,ink.b,14)); int ny=panel.y+76; for(int i=0;i<6;++i){ int height=std::max(42,wrappedHeight(tr(categories[i]),sidebar-32)+20); - if(i==int(current))gfx->drawFilledRect(panel.x+8,ny,sidebar-16,height,gold); - if(focus=="nav."+std::to_string(i))gfx->drawRect(panel.x+8,ny,sidebar-16,height,ink); + if(i==int(current))control({panel.x+8,ny,sidebar-16,height},gold,focus=="nav."+std::to_string(i),6); + else if(focus=="nav."+std::to_string(i))FrontendTheme::ring(gfx,panel.x+8,ny,sidebar-16,height,6,FrontendPalette::violet,1); drawWrapped(panel.x+16,ny+10,sidebar-32,tr(categories[i]));ny+=height+4; } } @@ -200,18 +216,17 @@ void SettingsScreen::paint() for(const auto& r:form)paintRow(r); gfx->setClipRect(); if(contentHeight>viewport.h){ - gfx->drawFilledRect(scrollbar.x,scrollbar.y,scrollbar.w,scrollbar.h,rail); + fill(scrollbar,Color(ink.r,ink.g,ink.b,40),3); int size=std::max(24,viewport.h*viewport.h/contentHeight); int top=scrollOffset()*(viewport.h-size)/std::max(1,contentHeight-viewport.h); - gfx->drawFilledRect(scrollbar.x,scrollbar.y+top,scrollbar.w,size,muted); + fill({scrollbar.x,scrollbar.y+top,scrollbar.w,size},muted,3); } - gfx->drawLine(footer.x,footer.y,footer.x+footer.w,footer.y,line); + rule(footer.x,footer.y,footer.w); std::string status=failed?tr("Could not save"):settingsDirty?tr("Saving…"):restartRequired()?tr("Saved — restart required"):tr("Changes saved automatically"); drawWrapped(footer.x+padding,footer.y+16,footer.w-240,status,true); dropdown.paint(gfx,ink,field,gold,line); const Rect doneRect={footer.x+footer.w-112,footer.y+12,96,40}; - gfx->drawFilledRect(doneRect.x,doneRect.y,doneRect.w,doneRect.h,gold); - gfx->drawRect(doneRect.x,doneRect.y,doneRect.w,doneRect.h,focus=="done"?ink:line); + control(doneRect,gold,focus=="done",6); drawText(doneRect.x+12,doneRect.y+10,tr(modal==Modal::None?"Done":modal==Modal::Display?"Revert":"Cancel")); - if(failed && modal==Modal::None){gfx->drawRect(doneRect.x-96,doneRect.y,88,40,focus=="retry"?ink:line);drawText(doneRect.x-88,doneRect.y+10,tr("Retry"));} + if(failed && modal==Modal::None){control({doneRect.x-96,doneRect.y,88,40},field,focus=="retry",6);drawText(doneRect.x-88,doneRect.y+10,tr("Retry"));} } diff --git a/src/render/GameRender.cpp b/src/render/GameRender.cpp index 09067a7eb..48e302450 100644 --- a/src/render/GameRender.cpp +++ b/src/render/GameRender.cpp @@ -173,7 +173,7 @@ void Game::drawMap(int sx, int sy, int sw, int sh, int rightMargin, int topMargi // compute and draw cloud shadow if we are in high quality - if ((globalContainer->settings.optionFlags & GlobalContainer::OPTION_LOW_SPEED_GFX) == 0) + if (!(drawOptions & DRAW_NO_CLOUDS) && (globalContainer->settings.optionFlags & GlobalContainer::OPTION_LOW_SPEED_GFX) == 0) { ds.compute(viewportX, viewportY, sw, sh, time, map.getW(), map.getH(), !(drawOptions & DRAW_NO_CLOUD_LAYER), cloudGridLimit); @@ -188,7 +188,7 @@ void Game::drawMap(int sx, int sy, int sw, int sh, int rightMargin, int topMargi // draw cloud overlay if we are in high quality - if (!(drawOptions & DRAW_NO_CLOUD_LAYER) && (globalContainer->settings.optionFlags & GlobalContainer::OPTION_LOW_SPEED_GFX) == 0) + if (!(drawOptions & (DRAW_NO_CLOUD_LAYER | DRAW_NO_CLOUDS)) && (globalContainer->settings.optionFlags & GlobalContainer::OPTION_LOW_SPEED_GFX) == 0) ds.render(globalContainer->gfx, sw, sh, DynamicClouds::CLOUD); // Draw units that are off the screen for the selected building diff --git a/tools/MenuColonyHarness.cpp b/tools/MenuColonyHarness.cpp index b175fe54d..c3fb6c4aa 100644 --- a/tools/MenuColonyHarness.cpp +++ b/tools/MenuColonyHarness.cpp @@ -175,8 +175,10 @@ template class Preview : public T { public: using T::T; - void prepare() { if(!prepared) { this->gfx=globalContainer->gfx; this->dispatchInit(); prepared=true; } } - void render() { prepare(); this->paint(); for(auto* w:this->widgets) if(w->visible) w->paint(); } + // A preview is the settled screen: past the ten-frame entry animation. + void prepare() { if(!prepared) { this->gfx=globalContainer->gfx; this->dispatchInit(); this->animationFrame=10; prepared=true; } } + // Mirrors Screen::dispatchPaint, including the style's pass over the finished frame. + void render() { prepare(); this->paint(); for(auto* w:this->widgets) if(w->visible) w->paint(); GAGGUI::Style::style->afterPaint(this->gfx); } void advance(unsigned frames) { prepare(); for(unsigned i=0;idispatchTimer(i*40); } void checkBounds() {