tetraq/lib/ui/game/board_painter.dart
2026-03-04 14:27:15 +01:00

262 lines
No EOL
13 KiB
Dart

// ===========================================================================
// FILE: lib/ui/game/board_painter.dart
// ===========================================================================
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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;
final bool isOnline;
final bool isVsCPU;
final bool isSetupPhase;
final Player myPlayer;
final Player jokerTurn;
final double cameraAngle; // Angolazione della telecamera a 360 gradi!
BoardPainter({
required this.board,
required this.theme,
required this.themeType,
required this.isOnline,
required this.isVsCPU,
required this.isSetupPhase,
required this.myPlayer,
required this.jokerTurn,
this.blinkValue = 0.0,
this.cameraAngle = 0.0,
});
Color _darken(Color c, [double amount = .1]) {
assert(amount >= 0 && amount <= 1);
final hsl = HSLColor.fromColor(c);
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
return hslDark.toColor();
}
// LA MAGIA: Proiezione Isometrica Ruotabile
Offset projectLogical(double x, double y, double z, Size size) {
if (board.shape != ArenaShape.pyramid3D) {
int gridPoints = board.columns + 1;
double spacing = size.width / gridPoints;
return Offset(x * spacing + (spacing / 2), y * spacing + (spacing / 2));
}
double tileW = size.width / 3.8;
double tileH = tileW * 0.55;
double zHeight = tileW * 0.45; // L'altezza fisica del blocco 3D
// Calcoliamo la posizione tenendo conto del restringimento della piramide (+ z * 0.5)
double actualX = x + z * 0.5 - board.columns / 2.0;
double actualY = y + z * 0.5 - board.rows / 2.0;
// Matrice di rotazione della telecamera (Z-Axis)
double rx = actualX * cos(cameraAngle) - actualY * sin(cameraAngle);
double ry = actualX * sin(cameraAngle) + actualY * cos(cameraAngle);
// Proiezione Isometrica 2D
double sx = (rx - ry) * tileW;
// Il SEGRETO: -z sposta fisicamente in ALTO la faccia del cubo rispetto allo schermo
double sy = (rx + ry) * tileH - (z * zHeight);
return Offset(size.width / 2 + sx, size.height * 0.70 + sy);
}
// Calcola la distanza dalla telecamera per lo Z-Buffering
double getDepth(Box b) {
double actualX = b.x + b.z * 0.5 - board.columns / 2.0;
double actualY = b.y + b.z * 0.5 - board.rows / 2.0;
double rx = actualX * cos(cameraAngle) - actualY * sin(cameraAngle);
double ry = actualX * sin(cameraAngle) + actualY * cos(cameraAngle);
return rx + ry; // Ordina dal più lontano al più vicino
}
@override
void paint(Canvas canvas, Size size) {
if (board.shape == ArenaShape.pyramid3D) _paint3D(canvas, size);
else _paint2D(canvas, size);
}
void _paint3D(Canvas canvas, Size size) {
double visualZHeight = (size.width / 3.8) * 0.45;
int maxZ = 4;
Set<Line> drawnLines = {};
// Costruiamo la piramide piano per piano, partendo dal basso
for (int currentZ = 0; currentZ < maxZ; currentZ++) {
var currentLevelBoxes = board.boxes.where((b) => b.z == currentZ && b.type != BoxType.invisible).toList();
// Ordiniamo dal fondo allo schermo
currentLevelBoxes.sort((a, b) => getDepth(a).compareTo(getDepth(b)));
for (var box in currentLevelBoxes) {
bool isPlayable = box.top.isPlayable;
bool isOwned = box.owner != Player.none;
if (!isOwned && !isPlayable) continue;
// Disegniamo prima le LINEE DELLA GRIGLIA relative a questo cubo
void drawLine(Line l, double lx1, double ly1, double lx2, double ly2) {
if (drawnLines.contains(l)) return;
drawnLines.add(l);
if (!l.isPlayable && l.owner == Player.none) return;
Offset pt1 = projectLogical(lx1, ly1, currentZ.toDouble(), size);
Offset pt2 = projectLogical(lx2, ly2, currentZ.toDouble(), size);
if (l.isIceCracked) { _drawCrackedIceLine(canvas, pt1, pt2, blinkValue); return; }
Color lineColor = l.owner == Player.none ? theme.gridLine.withOpacity(0.5) : (l.owner == Player.red ? theme.playerRed : theme.playerBlue);
if (l == board.lastMove && l.owner != Player.none) canvas.drawLine(pt1, pt2, Paint()..color = Colors.white.withOpacity(blinkValue * 0.8)..strokeWidth = 10.0..strokeCap = StrokeCap.round..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4.0));
canvas.drawLine(pt1, pt2, Paint()..color = lineColor..strokeWidth = 4.0..strokeCap = StrokeCap.round);
}
drawLine(box.top, box.x.toDouble(), box.y.toDouble(), box.x + 1.0, box.y.toDouble());
drawLine(box.right, box.x + 1.0, box.y.toDouble(), box.x + 1.0, box.y + 1.0);
drawLine(box.bottom, box.x.toDouble(), box.y + 1.0, box.x + 1.0, box.y + 1.0);
drawLine(box.left, box.x.toDouble(), box.y.toDouble(), box.x.toDouble(), box.y + 1.0);
// Disegniamo i PALLINI
void drawDot(Dot d) {
if (board.lines.any((l) => (l.p1 == d || l.p2 == d) && l.isPlayable)) {
canvas.drawCircle(projectLogical(d.x.toDouble(), d.y.toDouble(), currentZ.toDouble(), size), 5.0, Paint()..color = theme.text.withOpacity(0.8));
}
}
drawDot(box.top.p1); drawDot(box.top.p2); drawDot(box.bottom.p2); drawDot(box.bottom.p1);
// --- IL CUBO SOLIDO ---
if (isOwned) {
// Calcoliamo i 4 vertici del TETTO (Z + 1)
List<Offset> topCorners = [
projectLogical(box.x.toDouble(), box.y.toDouble(), currentZ + 1.0, size),
projectLogical(box.x + 1.0, box.y.toDouble(), currentZ + 1.0, size),
projectLogical(box.x + 1.0, box.y + 1.0, currentZ + 1.0, size),
projectLogical(box.x.toDouble(), box.y + 1.0, currentZ + 1.0, size),
];
// Algoritmo Geometrico Infallibile: troviamo la silhouette
topCorners.sort((a, b) => a.dy.compareTo(b.dy));
Offset screenTop = topCorners[0]; // Il punto più alto sullo schermo
Offset screenBottom = topCorners[3]; // Il punto più basso sullo schermo
Offset screenLeft = topCorners[1].dx < topCorners[2].dx ? topCorners[1] : topCorners[2];
Offset screenRight = topCorners[1].dx > topCorners[2].dx ? topCorners[1] : topCorners[2];
Color baseColor = box.owner == Player.red ? theme.playerRed : theme.playerBlue;
// PARETE SINISTRA: Dal tetto scende verso il basso
Path leftWall = Path()
..moveTo(screenLeft.dx, screenLeft.dy)
..lineTo(screenBottom.dx, screenBottom.dy)
..lineTo(screenBottom.dx, screenBottom.dy + visualZHeight)
..lineTo(screenLeft.dx, screenLeft.dy + visualZHeight)
..close();
canvas.drawPath(leftWall, Paint()..color = _darken(baseColor, 0.15)..style = PaintingStyle.fill);
canvas.drawPath(leftWall, Paint()..color = Colors.black.withOpacity(0.3)..style = PaintingStyle.stroke..strokeWidth = 1.0);
// PARETE DESTRA: Dal tetto scende verso il basso
Path rightWall = Path()
..moveTo(screenBottom.dx, screenBottom.dy)
..lineTo(screenRight.dx, screenRight.dy)
..lineTo(screenRight.dx, screenRight.dy + visualZHeight)
..lineTo(screenBottom.dx, screenBottom.dy + visualZHeight)
..close();
canvas.drawPath(rightWall, Paint()..color = _darken(baseColor, 0.35)..style = PaintingStyle.fill);
canvas.drawPath(rightWall, Paint()..color = Colors.black.withOpacity(0.3)..style = PaintingStyle.stroke..strokeWidth = 1.0);
// IL TETTO: Disegnato per ultimo, copre i muri ed è solido
Path roof = Path()..moveTo(screenTop.dx, screenTop.dy)..lineTo(screenRight.dx, screenRight.dy)..lineTo(screenBottom.dx, screenBottom.dy)..lineTo(screenLeft.dx, screenLeft.dy)..close();
canvas.drawPath(roof, Paint()..color = baseColor..style = PaintingStyle.fill);
canvas.drawPath(roof, Paint()..color = Colors.white.withOpacity(0.5)..style = PaintingStyle.stroke..strokeWidth = 2.0);
_drawBoxIcon(canvas, roof, box);
} else if (isPlayable) {
// PAVIMENTO VUOTO
Offset f0 = projectLogical(box.x.toDouble(), box.y.toDouble(), currentZ.toDouble(), size);
Offset f1 = projectLogical(box.x + 1.0, box.y.toDouble(), currentZ.toDouble(), size);
Offset f2 = projectLogical(box.x + 1.0, box.y + 1.0, currentZ.toDouble(), size);
Offset f3 = projectLogical(box.x.toDouble(), box.y + 1.0, currentZ.toDouble(), size);
Path floor = Path()..moveTo(f0.dx, f0.dy)..lineTo(f1.dx, f1.dy)..lineTo(f2.dx, f2.dy)..lineTo(f3.dx, f3.dy)..close();
canvas.drawPath(floor, Paint()..color = Colors.white.withOpacity(0.08)..style = PaintingStyle.fill);
_drawBoxIcon(canvas, floor, box);
}
}
}
}
void _paint2D(Canvas canvas, Size size) {
for (var box in board.boxes) {
if (box.type == BoxType.invisible) continue;
Offset p1 = projectLogical(box.top.p1.x.toDouble(), box.top.p1.y.toDouble(), 0, size);
Offset p2 = projectLogical(box.top.p2.x.toDouble(), box.top.p2.y.toDouble(), 0, size);
Offset p3 = projectLogical(box.bottom.p2.x.toDouble(), box.bottom.p2.y.toDouble(), 0, size);
Offset p4 = projectLogical(box.bottom.p1.x.toDouble(), box.bottom.p1.y.toDouble(), 0, size);
Path poly = Path()..moveTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..lineTo(p3.dx, p3.dy)..lineTo(p4.dx, p4.dy)..close();
if (box.owner != Player.none) {
Color c = box.owner == Player.red ? theme.playerRed : theme.playerBlue;
canvas.drawPath(poly, Paint()..color = c.withOpacity(0.85)..style = PaintingStyle.fill);
}
_drawBoxIcon(canvas, poly, box);
}
for (var line in board.lines) {
if (!line.isPlayable && line.owner == Player.none) continue;
Offset p1 = projectLogical(line.p1.x.toDouble(), line.p1.y.toDouble(), 0, size);
Offset p2 = projectLogical(line.p2.x.toDouble(), line.p2.y.toDouble(), 0, size);
if (line.isIceCracked) { _drawCrackedIceLine(canvas, p1, p2, blinkValue); continue; }
Color lineColor = line.owner == Player.none ? theme.gridLine.withOpacity(0.4) : (line.owner == Player.red ? theme.playerRed : theme.playerBlue);
if (line == board.lastMove && line.owner != Player.none) canvas.drawLine(p1, p2, Paint()..color = Colors.white.withOpacity(blinkValue * 0.8)..strokeWidth = 12.0..strokeCap = StrokeCap.round..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4.0));
canvas.drawLine(p1, p2, Paint()..color = lineColor..strokeWidth = (line.owner == Player.none ? 3.0 : 6.0)..strokeCap = StrokeCap.round);
}
for (var dot in board.dots) {
bool isVisible = board.lines.any((l) => (l.p1 == dot || l.p2 == dot) && l.isPlayable);
if (isVisible) canvas.drawCircle(projectLogical(dot.x.toDouble(), dot.y.toDouble(), 0, size), 4.0, Paint()..color = theme.text.withOpacity(0.8));
}
}
void _drawBoxIcon(Canvas canvas, Path face, Box box) {
if (box.type == BoxType.gold) _drawIconOnPath(canvas, face, FontAwesomeIcons.crown, Colors.amber);
else if (box.type == BoxType.bomb) _drawIconOnPath(canvas, face, FontAwesomeIcons.skull, Colors.redAccent);
else if (box.type == BoxType.swap) _drawIconOnPath(canvas, face, FontAwesomeIcons.arrowsRotate, Colors.purpleAccent);
else if (box.type == BoxType.multiplier) _drawIconOnPath(canvas, face, FontAwesomeIcons.bolt, Colors.yellowAccent);
else if (box.type == BoxType.ice && box.owner == Player.none) {
canvas.drawPath(face, Paint()..color = Colors.cyanAccent.withOpacity(0.15)..style = PaintingStyle.fill);
_drawIconOnPath(canvas, face, FontAwesomeIcons.snowflake, Colors.cyanAccent);
}
}
void _drawIconOnPath(Canvas canvas, Path path, IconData icon, Color color) {
Rect bounds = path.getBounds();
TextPainter tp = TextPainter(text: TextSpan(text: String.fromCharCode(icon.codePoint), style: TextStyle(color: color, fontSize: 16, fontFamily: icon.fontFamily, package: icon.fontPackage)), textDirection: TextDirection.ltr)..layout();
tp.paint(canvas, Offset(bounds.center.dx - tp.width / 2, bounds.center.dy - tp.height / 2));
}
void _drawCrackedIceLine(Canvas canvas, Offset p1, Offset p2, double blink) {
Paint crackPaint = Paint()..color = Colors.cyanAccent.withOpacity(0.6 + (0.4 * blink))..strokeWidth = 3.0..style = PaintingStyle.stroke..strokeCap = StrokeCap.round..maskFilter = const MaskFilter.blur(BlurStyle.solid, 2.0);
canvas.drawLine(p1, p2, Paint()..color = Colors.cyan.withOpacity(0.2)..strokeWidth=6.0);
double dx = p2.dx - p1.dx; double dy = p2.dy - p1.dy;
double len = sqrt(dx * dx + dy * dy);
if (len == 0) return;
double ndx = dx / len; double ndy = dy / len;
Path crack = Path()..moveTo(p1.dx, p1.dy);
for (int i = 1; i < 6; i++) {
double offset = (i % 2 == 0 ? 3.0 : -3.0);
crack.lineTo(p1.dx + ndx * (len * (i / 6)) + (-ndy) * offset, p1.dy + ndy * (len * (i / 6)) + ndx * offset);
}
crack.lineTo(p2.dx, p2.dy);
canvas.drawPath(crack, crackPaint);
}
@override bool shouldRepaint(covariant BoardPainter oldDelegate) => true;
}