337 lines
14 KiB
Dart
337 lines
14 KiB
Dart
|
|
// ===========================================================================
|
||
|
|
// FILE: lib/ui/game/board_painter.dart
|
||
|
|
// ===========================================================================
|
||
|
|
|
||
|
|
import 'dart:math';
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import '../../models/game_board.dart';
|
||
|
|
import '../../core/app_colors.dart';
|
||
|
|
|
||
|
|
class BoardPainter extends CustomPainter {
|
||
|
|
final GameBoard board;
|
||
|
|
final ThemeColors theme;
|
||
|
|
final AppThemeType themeType;
|
||
|
|
final double blinkValue;
|
||
|
|
|
||
|
|
BoardPainter({required this.board, required this.theme, required this.themeType, this.blinkValue = 0.0});
|
||
|
|
|
||
|
|
@override
|
||
|
|
void paint(Canvas canvas, Size size) {
|
||
|
|
if (themeType == AppThemeType.doodle) {
|
||
|
|
final Paint paperGridPaint = Paint()
|
||
|
|
..color = Colors.grey.withOpacity(0.3)
|
||
|
|
..strokeWidth = 1.0
|
||
|
|
..style = PaintingStyle.stroke;
|
||
|
|
|
||
|
|
double paperStep = 20.0;
|
||
|
|
for (double i = 0; i <= size.width; i += paperStep) {
|
||
|
|
canvas.drawLine(Offset(i, 0), Offset(i, size.height), paperGridPaint);
|
||
|
|
}
|
||
|
|
for (double i = 0; i <= size.height; i += paperStep) {
|
||
|
|
canvas.drawLine(Offset(0, i), Offset(size.width, i), paperGridPaint);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
int gridPoints = board.radius * 2 + 2;
|
||
|
|
double spacing = size.width / gridPoints;
|
||
|
|
double offset = spacing / 2;
|
||
|
|
Offset getScreenPos(int x, int y) => Offset(x * spacing + offset, y * spacing + offset);
|
||
|
|
|
||
|
|
// --- 1. DISEGNO AREE CONQUISTATE E ICONE ---
|
||
|
|
for (var box in board.boxes) {
|
||
|
|
if (box.type == BoxType.invisible) continue;
|
||
|
|
|
||
|
|
Offset p1 = getScreenPos(box.x, box.y);
|
||
|
|
Offset p2 = getScreenPos(box.x + 1, box.y + 1);
|
||
|
|
Rect rect = Rect.fromPoints(p1, p2);
|
||
|
|
|
||
|
|
if (box.owner != Player.none) {
|
||
|
|
final boxPaint = Paint()
|
||
|
|
..style = PaintingStyle.fill
|
||
|
|
..color = box.owner == Player.red ? theme.playerRed.withOpacity(0.6) : theme.playerBlue.withOpacity(0.6);
|
||
|
|
|
||
|
|
if (themeType == AppThemeType.wood) {
|
||
|
|
_drawFlameBox(canvas, rect, box.owner == Player.red);
|
||
|
|
} else if (themeType == AppThemeType.doodle) {
|
||
|
|
Color penColor = box.owner == Player.red ? Colors.redAccent.shade700 : Colors.blueAccent.shade700;
|
||
|
|
_drawScribbleBox(canvas, rect, penColor);
|
||
|
|
} else {
|
||
|
|
canvas.drawRect(rect, boxPaint);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (box.type == BoxType.gold) {
|
||
|
|
_drawIconInBox(canvas, rect, Icons.star_rounded, Colors.amber);
|
||
|
|
} else if (box.type == BoxType.bomb) {
|
||
|
|
_drawIconInBox(canvas, rect, Icons.mood_bad_rounded, themeType == AppThemeType.cyberpunk ? Colors.greenAccent : Colors.deepPurple);
|
||
|
|
} else if (box.type == BoxType.swap) {
|
||
|
|
// NUOVA ICONA SWAP: Frecce circolari viola (o cyan) per indicare l'inversione
|
||
|
|
_drawIconInBox(canvas, rect, Icons.sync_rounded, Colors.purpleAccent);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- 2. DISEGNO LINEE CON EFFETTO LAMPEGGIAMENTO ---
|
||
|
|
for (var line in board.lines) {
|
||
|
|
if (!line.isPlayable) continue;
|
||
|
|
|
||
|
|
Offset p1 = getScreenPos(line.p1.x, line.p1.y);
|
||
|
|
Offset p2 = getScreenPos(line.p2.x, line.p2.y);
|
||
|
|
|
||
|
|
bool isLastMove = (line == board.lastMove);
|
||
|
|
|
||
|
|
Color lineColor = line.owner == Player.none
|
||
|
|
? theme.gridLine.withOpacity(0.4)
|
||
|
|
: (line.owner == Player.red ? theme.playerRed : theme.playerBlue);
|
||
|
|
|
||
|
|
if (isLastMove && line.owner != Player.none && themeType != AppThemeType.wood && themeType != AppThemeType.cyberpunk) {
|
||
|
|
canvas.drawLine(p1, p2, Paint()
|
||
|
|
..color = Colors.white.withOpacity(blinkValue * 0.5)
|
||
|
|
..strokeWidth = 16.0
|
||
|
|
..strokeCap = StrokeCap.round
|
||
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6.0)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (themeType == AppThemeType.wood) {
|
||
|
|
if (line.owner == Player.none) {
|
||
|
|
canvas.drawLine(p1, p2, Paint()..color = const Color(0xFF3E2723).withOpacity(0.3)..strokeWidth = 4.5..strokeCap = StrokeCap.round);
|
||
|
|
} else {
|
||
|
|
Color headColor = lineColor;
|
||
|
|
if (isLastMove) {
|
||
|
|
headColor = Color.lerp(headColor, Colors.yellow, blinkValue * 0.8) ?? headColor;
|
||
|
|
}
|
||
|
|
_drawRealisticMatch(canvas, p1, p2, headColor, isLastMove: isLastMove, blinkValue: blinkValue);
|
||
|
|
}
|
||
|
|
} else if (themeType == AppThemeType.cyberpunk) {
|
||
|
|
_drawNeonLine(canvas, p1, p2, lineColor, line.owner != Player.none, isLastMove: isLastMove, blinkValue: blinkValue);
|
||
|
|
} else if (themeType == AppThemeType.doodle) {
|
||
|
|
Color doodleColor = line.owner == Player.none ? Colors.black.withOpacity(0.05) : lineColor;
|
||
|
|
if (isLastMove && line.owner != Player.none) {
|
||
|
|
doodleColor = Color.lerp(doodleColor, Colors.black, blinkValue * 0.4) ?? doodleColor;
|
||
|
|
}
|
||
|
|
_drawWobblyLine(canvas, p1, p2, doodleColor, line.owner != Player.none, isLastMove: isLastMove, blinkValue: blinkValue);
|
||
|
|
} else {
|
||
|
|
if (isLastMove && line.owner != Player.none) {
|
||
|
|
lineColor = Color.lerp(lineColor, Colors.white, blinkValue * 0.5) ?? lineColor;
|
||
|
|
}
|
||
|
|
canvas.drawLine(p1, p2, Paint()..color = lineColor..strokeWidth = isLastMove ? 6.0 + (2.0 * blinkValue) : 6.0..strokeCap = StrokeCap.round);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- 3. DISEGNO PUNTINI ---
|
||
|
|
final dotPaint = Paint()..style = PaintingStyle.fill;
|
||
|
|
|
||
|
|
Set<Dot> activeDots = {};
|
||
|
|
for (var line in board.lines) {
|
||
|
|
if (line.isPlayable) {
|
||
|
|
activeDots.add(line.p1);
|
||
|
|
activeDots.add(line.p2);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (var dot in activeDots) {
|
||
|
|
Offset pos = getScreenPos(dot.x, dot.y);
|
||
|
|
if (themeType == AppThemeType.wood) {
|
||
|
|
canvas.drawCircle(pos, 3.5, dotPaint..color = const Color(0xFF3E2723).withOpacity(0.2));
|
||
|
|
} else if (themeType == AppThemeType.cyberpunk) {
|
||
|
|
canvas.drawCircle(pos, 6.0, Paint()..color = theme.gridLine.withOpacity(0.3));
|
||
|
|
canvas.drawCircle(pos, 3.0, Paint()..color = Colors.white.withOpacity(0.5));
|
||
|
|
} else if (themeType == AppThemeType.doodle) {
|
||
|
|
canvas.drawRect(Rect.fromCenter(center: pos, width: 4, height: 4), dotPaint..color = Colors.black.withOpacity(0.25));
|
||
|
|
} else {
|
||
|
|
canvas.drawCircle(pos, 5.0, dotPaint..color = theme.text.withOpacity(0.6));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _drawIconInBox(Canvas canvas, Rect rect, IconData icon, Color color) {
|
||
|
|
TextPainter textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||
|
|
textPainter.text = TextSpan(
|
||
|
|
text: String.fromCharCode(icon.codePoint),
|
||
|
|
style: TextStyle(
|
||
|
|
color: color.withOpacity(0.7),
|
||
|
|
fontSize: rect.width * 0.45,
|
||
|
|
fontFamily: icon.fontFamily,
|
||
|
|
package: icon.fontPackage,
|
||
|
|
shadows: [Shadow(color: color.withOpacity(0.6), blurRadius: 10, offset: const Offset(0, 0))]
|
||
|
|
),
|
||
|
|
);
|
||
|
|
textPainter.layout();
|
||
|
|
textPainter.paint(canvas, Offset(rect.center.dx - textPainter.width / 2, rect.center.dy - textPainter.height / 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
void _drawFlameBox(Canvas canvas, Rect baseRect, bool isRed) {
|
||
|
|
final rand = Random((baseRect.left + baseRect.top).toInt());
|
||
|
|
Offset center = baseRect.center;
|
||
|
|
double w = baseRect.width * 0.35;
|
||
|
|
double h = baseRect.height * 0.55;
|
||
|
|
Offset bottomCenter = Offset(center.dx, center.dy + h * 0.5);
|
||
|
|
|
||
|
|
Color outerColor = isRed ? Colors.red.shade600.withOpacity(0.85) : Colors.blue.shade700.withOpacity(0.85);
|
||
|
|
Color midColor = isRed ? Colors.orangeAccent : Colors.lightBlueAccent;
|
||
|
|
Color coreColor = isRed ? Colors.yellowAccent : Colors.white;
|
||
|
|
|
||
|
|
canvas.drawOval(
|
||
|
|
Rect.fromCenter(center: bottomCenter, width: w * 1.5, height: w * 0.5),
|
||
|
|
Paint()
|
||
|
|
..color = Colors.black.withOpacity(0.4)
|
||
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4.0),
|
||
|
|
);
|
||
|
|
|
||
|
|
void drawFlameLayer(double scale, Color color, double tipOffsetX) {
|
||
|
|
Path path = Path();
|
||
|
|
double fw = w * scale;
|
||
|
|
double fh = h * scale;
|
||
|
|
|
||
|
|
path.moveTo(bottomCenter.dx, bottomCenter.dy);
|
||
|
|
path.cubicTo(
|
||
|
|
bottomCenter.dx + fw, bottomCenter.dy,
|
||
|
|
bottomCenter.dx + fw * 0.8, bottomCenter.dy - fh * 0.6,
|
||
|
|
bottomCenter.dx + tipOffsetX, bottomCenter.dy - fh,
|
||
|
|
);
|
||
|
|
path.cubicTo(
|
||
|
|
bottomCenter.dx - fw * 0.8, bottomCenter.dy - fh * 0.6,
|
||
|
|
bottomCenter.dx - fw, bottomCenter.dy,
|
||
|
|
bottomCenter.dx, bottomCenter.dy,
|
||
|
|
);
|
||
|
|
|
||
|
|
canvas.drawPath(
|
||
|
|
path,
|
||
|
|
Paint()
|
||
|
|
..color = color
|
||
|
|
..style = PaintingStyle.fill
|
||
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
double randomTipX = (rand.nextDouble() - 0.5) * w * 0.8;
|
||
|
|
drawFlameLayer(1.0, outerColor, randomTipX);
|
||
|
|
drawFlameLayer(0.65, midColor.withOpacity(0.9), randomTipX * 0.6);
|
||
|
|
drawFlameLayer(0.35, coreColor.withOpacity(0.9), randomTipX * 0.2);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _drawScribbleBox(Canvas canvas, Rect baseRect, Color color) {
|
||
|
|
final rand = Random((baseRect.left + baseRect.top).toInt());
|
||
|
|
final paint = Paint()
|
||
|
|
..color = color.withOpacity(0.85)
|
||
|
|
..style = PaintingStyle.stroke
|
||
|
|
..strokeWidth = 3.5
|
||
|
|
..strokeCap = StrokeCap.round
|
||
|
|
..strokeJoin = StrokeJoin.round;
|
||
|
|
|
||
|
|
final path = Path();
|
||
|
|
Rect rect = baseRect.deflate(4.0);
|
||
|
|
|
||
|
|
int numZigs = 15 + rand.nextInt(6);
|
||
|
|
double stepY = rect.height / numZigs;
|
||
|
|
|
||
|
|
path.moveTo(rect.left + rand.nextDouble() * 5, rect.top + rand.nextDouble() * 5);
|
||
|
|
|
||
|
|
for (int i = 1; i <= numZigs; i++) {
|
||
|
|
double targetX = (i % 2 != 0) ? rect.right + (rand.nextDouble() * 4 - 2) : rect.left + (rand.nextDouble() * 4 - 2);
|
||
|
|
double targetY = rect.top + stepY * i + (rand.nextDouble() - 0.5) * 3;
|
||
|
|
double ctrlX = rect.center.dx + (rand.nextDouble() - 0.5) * 20;
|
||
|
|
double ctrlY = targetY - stepY / 2;
|
||
|
|
path.quadraticBezierTo(ctrlX, ctrlY, targetX, targetY);
|
||
|
|
}
|
||
|
|
canvas.drawPath(path, paint);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _drawRealisticMatch(Canvas canvas, Offset p1, Offset p2, Color headColor, {bool isLastMove = false, double blinkValue = 0.0}) {
|
||
|
|
int seed = (p1.dx * 1000 + p1.dy).toInt();
|
||
|
|
Random rand = Random(seed);
|
||
|
|
Vector2 dir = Vector2(p2.dx - p1.dx, p2.dy - p1.dy).normalized();
|
||
|
|
double shrink = 8.0;
|
||
|
|
Offset start = Offset(p1.dx + dir.x * shrink, p1.dy + dir.y * shrink);
|
||
|
|
Offset end = Offset(p2.dx - dir.x * shrink, p2.dy - dir.y * shrink);
|
||
|
|
start += Offset(rand.nextDouble() * 4 - 2, rand.nextDouble() * 4 - 2);
|
||
|
|
end += Offset(rand.nextDouble() * 4 - 2, rand.nextDouble() * 4 - 2);
|
||
|
|
bool headAtEnd = rand.nextBool();
|
||
|
|
Offset headPos = headAtEnd ? end : start;
|
||
|
|
Offset tailPos = headAtEnd ? start : end;
|
||
|
|
Vector2 matchDir = Vector2(headPos.dx - tailPos.dx, headPos.dy - tailPos.dy).normalized();
|
||
|
|
|
||
|
|
canvas.drawLine(tailPos + const Offset(4, 4), headPos + const Offset(4, 4), Paint()..color = Colors.black.withOpacity(0.6)..strokeWidth = 7.0..strokeCap = StrokeCap.round);
|
||
|
|
|
||
|
|
if (isLastMove) {
|
||
|
|
canvas.drawCircle(headPos, 8.0 + (blinkValue * 6.0), Paint()
|
||
|
|
..color = Colors.orangeAccent.withOpacity(0.6 * blinkValue)
|
||
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6.0)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
canvas.drawLine(tailPos, headPos, Paint()..color = const Color(0xFF6D4C41)..strokeWidth = 7.0..strokeCap = StrokeCap.round);
|
||
|
|
canvas.drawLine(tailPos, headPos, Paint()..color = const Color(0xFFEDC498)..strokeWidth = 4.0..strokeCap = StrokeCap.round);
|
||
|
|
Offset burnPos = Offset(headPos.dx - matchDir.x * 8, headPos.dy - matchDir.y * 8);
|
||
|
|
canvas.drawLine(burnPos, headPos, Paint()..color = const Color(0xFF2E1A14)..strokeWidth = 6.0..strokeCap = StrokeCap.round);
|
||
|
|
|
||
|
|
canvas.save();
|
||
|
|
canvas.translate(headPos.dx, headPos.dy);
|
||
|
|
double angle = atan2(matchDir.y, matchDir.x);
|
||
|
|
canvas.rotate(angle);
|
||
|
|
Rect headOval = Rect.fromCenter(center: Offset.zero, width: 18.0, height: 13.0);
|
||
|
|
canvas.drawOval(headOval.shift(const Offset(1, 2)), Paint()..color = Colors.black.withOpacity(0.6));
|
||
|
|
canvas.drawOval(headOval, Paint()..color = headColor);
|
||
|
|
canvas.restore();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _drawNeonLine(Canvas canvas, Offset p1, Offset p2, Color color, bool isConquered, {bool isLastMove = false, double blinkValue = 0.0}) {
|
||
|
|
double mainWidth = isConquered ? (isLastMove ? 6.0 + (blinkValue * 3.0) : 6.0) : 3.0;
|
||
|
|
Color coreColor = isConquered ? (isLastMove ? Color.lerp(Colors.white, color, 1.0 - blinkValue)! : Colors.white.withOpacity(0.9)) : color.withOpacity(0.6);
|
||
|
|
|
||
|
|
canvas.drawLine(p1, p2, Paint()
|
||
|
|
..color = color.withOpacity(isConquered ? (isLastMove ? 0.4 + (0.4 * blinkValue) : 0.4) : 0.2)
|
||
|
|
..strokeWidth = mainWidth * 4
|
||
|
|
..strokeCap = StrokeCap.round
|
||
|
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, isConquered ? 12.0 : 6.0)
|
||
|
|
);
|
||
|
|
|
||
|
|
if (isConquered) {
|
||
|
|
canvas.drawLine(p1, p2, Paint()
|
||
|
|
..color = color.withOpacity(isLastMove ? 0.7 + (0.3 * blinkValue) : 0.7)
|
||
|
|
..strokeWidth = mainWidth * 2
|
||
|
|
..strokeCap = StrokeCap.round
|
||
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6.0)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
canvas.drawLine(p1, p2, Paint()
|
||
|
|
..color = coreColor
|
||
|
|
..strokeWidth = mainWidth
|
||
|
|
..strokeCap = StrokeCap.round
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _drawWobblyLine(Canvas canvas, Offset p1, Offset p2, Color color, bool isConquered, {bool isLastMove = false, double blinkValue = 0.0}) {
|
||
|
|
final random = Random((p1.dx + p1.dy + p2.dx + p2.dy).toInt());
|
||
|
|
final dx = p2.dx - p1.dx;
|
||
|
|
final dy = p2.dy - p1.dy;
|
||
|
|
|
||
|
|
double strokeW = isConquered ? (isLastMove ? 4.5 + (2.0 * blinkValue) : 4.5) : 2.0;
|
||
|
|
|
||
|
|
final basePaint = Paint()
|
||
|
|
..color = color
|
||
|
|
..strokeWidth = strokeW
|
||
|
|
..style = PaintingStyle.stroke
|
||
|
|
..strokeCap = StrokeCap.round;
|
||
|
|
|
||
|
|
final mid1 = Offset(p1.dx + dx / 2 + (random.nextDouble() - 0.5) * 8, p1.dy + dy / 2 + (random.nextDouble() - 0.5) * 8);
|
||
|
|
canvas.drawPath(Path()..moveTo(p1.dx, p1.dy)..quadraticBezierTo(mid1.dx, mid1.dy, p2.dx, p2.dy), basePaint);
|
||
|
|
|
||
|
|
final mid2 = Offset(p1.dx + dx / 2 + (random.nextDouble() - 0.5) * 6, p1.dy + dy / 2 + (random.nextDouble() - 0.5) * 6);
|
||
|
|
canvas.drawPath(Path()..moveTo(p1.dx, p1.dy)..quadraticBezierTo(mid2.dx, mid2.dy, p2.dx, p2.dy), basePaint..strokeWidth = strokeW * 0.5..color = color.withOpacity(0.8));
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
bool shouldRepaint(covariant BoardPainter oldDelegate) => true;
|
||
|
|
}
|
||
|
|
|
||
|
|
class Vector2 {
|
||
|
|
final double x, y;
|
||
|
|
Vector2(this.x, this.y);
|
||
|
|
double get length => sqrt(x * x + y * y);
|
||
|
|
Vector2 normalized() {
|
||
|
|
double l = length;
|
||
|
|
return l == 0 ? Vector2(0, 0) : Vector2(x / l, y / l);
|
||
|
|
}
|
||
|
|
}
|