feat: glossy tile rendering and per-season theme colors

Introduces candy-gloss tile rendering (diagonal gradient + glass highlight
+ optional glow) via a shared paintGlossyTile() in tile_painter.dart,
applied to board filled-cells and tray/drag-overlay pieces. Adds
ThemeColors to palette.dart for UI-layer season color resolution, and
activeThemeProvider for one-call access to the active season's theme.
Regenerates the game_screen golden to reflect the new look.
This commit is contained in:
2026-06-11 21:06:26 +09:00
parent 6bb1eac28c
commit 8739fc0e26
6 changed files with 104 additions and 32 deletions
+7
View File
@@ -45,3 +45,10 @@ final seasonsProvider = FutureProvider<List<SeasonPack>>(
final streakProvider = NotifierProvider<StreakNotifier, StreakState>( final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
StreakNotifier.new, StreakNotifier.new,
); );
/// The visual theme of whatever season is in play; fallback outside seasons
/// (home, endless). Pure model — UI converts via ThemeColors.
final activeThemeProvider = Provider<SeasonTheme>((ref) {
final flow = ref.watch(seasonFlowProvider);
return flow?.pack.theme ?? SeasonTheme.fallback;
});
+25
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../game/models/season.dart';
/// Season-themeable color set. Season 1 default: vivid candy tones on a /// Season-themeable color set. Season 1 default: vivid candy tones on a
/// deep navy board. /// deep navy board.
class GamePalette { class GamePalette {
@@ -24,3 +26,26 @@ class GamePalette {
static const ghostLegal = Color(0x66FFFFFF); static const ghostLegal = Color(0x66FFFFFF);
static const ghostIllegal = Color(0x55FF5252); static const ghostIllegal = Color(0x55FF5252);
} }
/// Resolved per-season colors for the UI layer. Built from a SeasonTheme;
/// falls back to the GamePalette constants.
class ThemeColors {
ThemeColors(SeasonTheme theme)
: gradient = [for (final c in theme.backgroundGradient) Color(c)],
accent = Color(theme.accentColor),
particleType = theme.particleType,
board = theme.boardTint != null
? Color(theme.boardTint!)
: GamePalette.boardBackground,
tiles = theme.tilePalette != null
? [for (final c in theme.tilePalette!) Color(c)]
: GamePalette.tileColors;
final List<Color> gradient;
final Color accent;
final String particleType;
final Color board;
final List<Color> tiles;
Color tile(int colorId) => tiles[colorId % tiles.length];
}
+19 -21
View File
@@ -6,6 +6,7 @@ import '../../game/models/piece.dart';
import '../theme/palette.dart'; import '../theme/palette.dart';
import 'board_geometry.dart'; import 'board_geometry.dart';
import 'piece_painter.dart'; import 'piece_painter.dart';
import 'tile_painter.dart';
/// Drag ghost preview: a piece hovering at a snapped anchor. /// Drag ghost preview: a piece hovering at a snapped anchor.
class GhostSpec { class GhostSpec {
@@ -55,27 +56,20 @@ class BoardPainter extends CustomPainter {
for (var x = 0; x < GridState.size; x++) { for (var x = 0; x < GridState.size; x++) {
final rect = geo.cellRect(x, y).deflate(inset); final rect = geo.cellRect(x, y).deflate(inset);
final cell = grid.cellAt(x, y); final cell = grid.cellAt(x, y);
final paint = Paint() switch (cell.type) {
..color = switch (cell.type) { case CellType.empty:
CellType.empty => GamePalette.emptyCell, canvas.drawRRect(
CellType.filled => GamePalette.tile(cell.colorId), RRect.fromRectAndRadius(rect, radius),
CellType.gem => GamePalette.emptyCell, Paint()..color = GamePalette.emptyCell,
}; );
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint); case CellType.filled:
paintGlossyTile(canvas, rect, GamePalette.tile(cell.colorId));
if (cell.type == CellType.gem) { case CellType.gem:
_paintGem(canvas, rect); canvas.drawRRect(
} else if (cell.type == CellType.filled) { RRect.fromRectAndRadius(rect, radius),
final highlight = Paint() Paint()..color = GamePalette.emptyCell,
..color = Colors.white.withValues(alpha: 0.15); );
canvas.drawRRect( _paintGem(canvas, rect);
RRect.fromRectAndRadius(
Rect.fromLTWH(
rect.left, rect.top, rect.width, rect.height * 0.32),
radius,
),
highlight,
);
} }
} }
} }
@@ -111,6 +105,10 @@ class BoardPainter extends CustomPainter {
} }
void _paintGem(Canvas canvas, Rect rect) { void _paintGem(Canvas canvas, Rect rect) {
final glowPaint = Paint()
..color = GamePalette.gem.withValues(alpha: 0.45)
..maskFilter = MaskFilter.blur(BlurStyle.normal, rect.width * 0.25);
canvas.drawCircle(rect.center, rect.width * 0.32, glowPaint);
final center = rect.center; final center = rect.center;
final r = rect.width * 0.32; final r = rect.width * 0.32;
final path = Path() final path = Path()
+6 -11
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../game/models/piece.dart'; import '../../game/models/piece.dart';
import '../theme/palette.dart'; import '../theme/palette.dart';
import 'tile_painter.dart';
/// Draws a piece as rounded tiles at a given cell size; reused by the tray, /// Draws a piece as rounded tiles at a given cell size; reused by the tray,
/// the drag overlay, and ghost previews. /// the drag overlay, and ghost previews.
@@ -12,8 +13,6 @@ void paintPiece(
Offset origin = Offset.zero, Offset origin = Offset.zero,
Color? overrideColor, Color? overrideColor,
}) { }) {
final paint = Paint()
..color = overrideColor ?? GamePalette.tile(piece.colorId);
final inset = cellSize * 0.05; final inset = cellSize * 0.05;
final radius = Radius.circular(cellSize * 0.18); final radius = Radius.circular(cellSize * 0.18);
for (final (dx, dy) in piece.offsets) { for (final (dx, dy) in piece.offsets) {
@@ -23,17 +22,13 @@ void paintPiece(
cellSize - inset * 2, cellSize - inset * 2,
cellSize - inset * 2, cellSize - inset * 2,
); );
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint); if (overrideColor != null) {
if (overrideColor == null) {
// Subtle top highlight for depth.
final highlight = Paint()..color = Colors.white.withValues(alpha: 0.18);
canvas.drawRRect( canvas.drawRRect(
RRect.fromRectAndRadius( RRect.fromRectAndRadius(rect, radius),
Rect.fromLTWH(rect.left, rect.top, rect.width, rect.height * 0.32), Paint()..color = overrideColor,
radius,
),
highlight,
); );
} else {
paintGlossyTile(canvas, rect, GamePalette.tile(piece.colorId));
} }
} }
} }
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
Color lighten(Color c, double amount) => Color.lerp(c, Colors.white, amount)!;
Color darken(Color c, double amount) => Color.lerp(c, Colors.black, amount)!;
/// Candy-gloss tile: diagonal gradient body, glass top highlight, optional
/// colored glow (used for gems and clear flashes). Shared by the board,
/// tray, and drag overlay so every tile in the game matches.
void paintGlossyTile(
Canvas canvas,
Rect rect,
Color color, {
double radiusFactor = 0.18,
double glow = 0,
}) {
final radius = Radius.circular(rect.width * radiusFactor);
final rrect = RRect.fromRectAndRadius(rect, radius);
if (glow > 0) {
final glowPaint = Paint()
..color = color.withValues(alpha: 0.55 * glow)
..maskFilter =
MaskFilter.blur(BlurStyle.normal, rect.width * 0.28 * glow);
canvas.drawRRect(rrect.inflate(rect.width * 0.05), glowPaint);
}
final body = Paint()
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [lighten(color, 0.28), color, darken(color, 0.22)],
stops: const [0.0, 0.45, 1.0],
).createShader(rect);
canvas.drawRRect(rrect, body);
final highlight = Paint()..color = Colors.white.withValues(alpha: 0.30);
final hl = Rect.fromLTWH(
rect.left + rect.width * 0.10,
rect.top + rect.height * 0.07,
rect.width * 0.80,
rect.height * 0.30,
);
canvas.drawRRect(
RRect.fromRectAndRadius(hl, Radius.circular(rect.width * 0.12)),
highlight,
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 31 KiB