// =========================================================================== // 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 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 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; }