Skip to content

Instantly share code, notes, and snippets.

@altuzar
Created April 19, 2025 18:20
Show Gist options
  • Save altuzar/98e968ae7960ad8aa8d34a14bcc8bb0c to your computer and use it in GitHub Desktop.
Save altuzar/98e968ae7960ad8aa8d34a14bcc8bb0c to your computer and use it in GitHub Desktop.
import React, { useRef, useEffect, useState, useCallback } from 'react';
import p5 from 'p5';
import { Link } from 'react-router-dom';
import { ArrowLeftIcon } from 'lucide-react';
import './connect-the-tequilas.css';
interface P5Instance extends p5 {
updateWithProps: (props: { level: number }) => void;
}
// Main React Component
const TequilaConnectGame: React.FC = () => {
const sketchRef = useRef<HTMLDivElement>(null); // Ref for the p5 canvas container
const p5InstanceRef = useRef<P5Instance | null>(null); // Ref to store the p5 instance
// --- React State ---
const [level, setLevel] = useState(1);
const [highScore, setHighScore] = useState(0);
const [isLevelComplete, setIsLevelComplete] = useState(false);
const [showNextButton, setShowNextButton] = useState(false);
const [tequilas, setTequilas] = useState(0);
const [canvasSize] = useState(600); // Canvas dimension
// --- High Score Loading ---
useEffect(() => {
const storedHighScore = localStorage.getItem('tequilaConnectHighScore');
if (storedHighScore) {
setHighScore(parseInt(storedHighScore));
}
}, []); // Load high score only once
// --- Reset Total Tequilas on Game Start ---
useEffect(() => {
setTequilas(0);
}, []); // Reset tequila points on mount
// --- p5 Sketch Logic ---
const Sketch = useCallback((p: p5) => {
// --- Game State Variables (managed within p5 sketch) ---
let currentLevel = 1; // Internal level state for the sketch
let gridSize = 4;
let cellSize: number;
let grid: Array<Array<null | { type: string; color: p5.Color; isStart?: boolean }>> = []; // Stores state of each cell: null=empty, color=tequila, path_color=path
let pairs: Array<{ color: p5.Color; start: { r: number; c: number }; end: { r: number; c: number }; connected: boolean }> = []; // { color, start: {r, c}, end: {r, c}, connected: false }
let paths: Record<string, Array<{ r: number; c: number }>> = {}; // { colorKey: [{r, c}, {r, c}, ...] }
let availableColors: Array<p5.Color> = []; // Pool of colors for tequilas
let isDrawing = false;
let currentDrawingColor: p5.Color | null = null; // p5 color object
let currentDrawingColorKey: string | null = null; // string key for paths object
let startNode: { r: number; c: number } | null = null; // The tequila node where drawing started
let levelCompleteInternal = false; // Internal flag for sketch logic
let levelStartTime: number;
const levelCompleteDisplayTime = 1500; // ms
// Confetti setup for level celebration
type ConfettiParticle = {
x: number; y: number; size: number; color: p5.Color;
vx: number; vy: number; rotation: number; rotationSpeed: number;
};
let confettiParticles: ConfettiParticle[] = [];
const GRID_LINE_COLOR = p.color(68, 68, 68); // '#444'
const BACKGROUND_COLOR = p.color(48, 48, 48); // '#303030'
const TEXT_COLOR = p.color(255, 255, 255); // '#FFFFFF'
const LEVEL_COMPLETE_OVERLAY = p.color(0, 0, 0, 180);
const LEVEL_COMPLETE_TEXT = p.color(255, 255, 255);
// --- Utility Functions (within sketch scope) ---
const colorToString = (p5Color: p5.Color | null): string => {
if (!p5Color) return 'null';
if (typeof p5Color === 'string') return p5Color as string; // Should not happen ideally
// Use RGBA values for a unique key
return `rgba(${p.red(p5Color)},${p.green(p5Color)},${p.blue(p5Color)},${p.alpha(p5Color)})`;
};
const colorKeyToColor = (key: string): p5.Color | null => {
if (!key || key === 'null') return null;
try {
const match = key.match(/rgba\((\d+),(\d+),(\d+),(\d+)\)/);
if (match) {
return p.color(parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4]));
}
} catch (e) {
console.error("Error parsing color key:", key, e);
}
return null;
};
const findPairByColor = (col: p5.Color | null): { color: p5.Color; start: { r: number; c: number }; end: { r: number; c: number }; connected: boolean } | undefined => {
if (!col) return undefined;
const colStr = col.toString(); // p5.Color comparison needs consistent format
return pairs.find(pair => pair.color.toString() === colStr);
};
const getGridPosFromMouse = (mx: number, my: number): { r: number; c: number } | null => {
if (mx < 0 || mx >= p.width || my < 0 || my >= p.height) {
return null;
}
const c = p.floor(mx / cellSize);
const r = p.floor(my / cellSize);
if (r < 0 || r >= gridSize || c < 0 || c >= gridSize) {
return null;
}
return { r, c };
};
const clearPath = (colorKey: string): void => {
const path = paths[colorKey];
const targetColor = colorKeyToColor(colorKey);
const targetPair = findPairByColor(targetColor);
if (path && path.length > 0) {
for (const pos of path) {
const isStart = targetPair && pos.r === targetPair.start.r && pos.c === targetPair.start.c;
const isEnd = targetPair && pos.r === targetPair.end.r && pos.c === targetPair.end.c;
if (!isStart && !isEnd) {
if (grid[pos.r][pos.c] && grid[pos.r][pos.c]?.type === 'path' && colorToString(grid[pos.r][pos.c]?.color) === colorKey) {
grid[pos.r][pos.c] = null;
}
} else {
grid[pos.r][pos.c] = { type: 'tequila', color: targetPair!.color, isStart: isStart };
}
}
}
paths[colorKey] = [];
if (targetPair) {
targetPair.connected = false;
}
};
// Generate confetti particles
const generateConfetti = (): void => {
confettiParticles = [];
const count = 200;
for (let i = 0; i < count; i++) {
const sizeC = p.random(5, 10);
// Spawn from a random screen border
const edge = p.floor(p.random(4)); // 0: top, 1: bottom, 2: left, 3: right
let x: number, y: number, vx: number, vy: number;
if (edge === 0) { // top
x = p.random(0, p.width);
y = p.random(-50, 0);
vx = p.random(-2, 2);
vy = p.random(2, 5);
} else if (edge === 1) { // bottom
x = p.random(0, p.width);
y = p.random(p.height, p.height + 50);
vx = p.random(-2, 2);
vy = p.random(-5, -2);
} else if (edge === 2) { // left
x = p.random(-50, 0);
y = p.random(0, p.height);
vx = p.random(2, 5);
vy = p.random(-2, 2);
} else { // right
x = p.random(p.width, p.width + 50);
y = p.random(0, p.height);
vx = p.random(-5, -2);
vy = p.random(-2, 2);
}
const col = p.color(p.random(255), p.random(255), p.random(255));
const rot = p.random(0, p.TWO_PI);
const rotSpeed = p.random(-0.1, 0.1);
confettiParticles.push({ x, y, size: sizeC, color: col, vx, vy, rotation: rot, rotationSpeed: rotSpeed });
}
};
const checkLevelCompletion = (): void => {
const allConnected = pairs.every(p => p.connected);
// Level is complete when all pairs are connected - no need to fill the entire grid
if (allConnected) {
console.log('Level complete! All pairs connected.');
levelCompleteInternal = true;
levelStartTime = p.millis();
setIsLevelComplete(true); // Update React state
// Start confetti
generateConfetti();
// Update high score logic (handled in React useEffect)
}
};
// --- Dynamic Tequila Sprite Drawing Function (NO EXTERNAL ASSETS) ---
const drawTequilaSprite = (x: number, y: number, size: number, fillColor: p5.Color): void => {
p.push(); // Isolate transformations and styles
p.translate(x, y); // Center the drawing at x, y
const bodyHeight = size * 0.6;
const neckHeight = size * 0.15;
const baseWidth = size * 0.4;
const topWidth = size * 0.3;
const neckWidth = size * 0.15;
const liquidLevel = bodyHeight * 0.8; // How full the glass is
const baseHeight = size * 0.1;
// Glass Outline
p.stroke(200, 200, 200, 180);
p.strokeWeight(p.max(1, size * 0.03));
p.fill(180, 180, 180, 50);
// Simple Shot Glass Shape
p.beginShape();
p.vertex(-baseWidth / 2, bodyHeight / 2);
p.vertex(-topWidth / 2, -bodyHeight / 2);
p.vertex(-neckWidth / 2, -bodyHeight / 2 - neckHeight);
p.vertex(neckWidth / 2, -bodyHeight / 2 - neckHeight);
p.vertex(topWidth / 2, -bodyHeight / 2);
p.vertex(baseWidth / 2, bodyHeight / 2);
p.vertex(baseWidth * 0.8 / 2, bodyHeight / 2 + baseHeight);
p.vertex(-baseWidth * 0.8 / 2, bodyHeight / 2 + baseHeight);
p.endShape(p.CLOSE);
// Tequila Liquid
p.noStroke();
p.fill(fillColor);
p.beginShape();
p.vertex(-baseWidth / 2, bodyHeight / 2);
p.vertex(-topWidth/2 * (liquidLevel/bodyHeight), -bodyHeight/2 + liquidLevel);
p.vertex(topWidth/2 * (liquidLevel/bodyHeight), -bodyHeight/2 + liquidLevel);
p.vertex(baseWidth / 2, bodyHeight / 2);
p.endShape(p.CLOSE);
// Optional: Lime Wedge
p.fill(0, 180, 0);
p.noStroke();
p.arc(-topWidth / 2 - size*0.05, -bodyHeight / 2 - neckHeight, size * 0.2, size * 0.25, p.PI, p.PI + p.HALF_PI);
// Optional: Highlight
p.fill(255, 255, 255, 100);
p.ellipse(-topWidth*0.2, -bodyHeight*0.1, size*0.05, size*0.15);
p.pop(); // Restore previous drawing state
};
// --- Level Setup ---
const setupLevel = (lvl: number): void => {
currentLevel = lvl; // Sync internal level with React state
levelCompleteInternal = false;
setIsLevelComplete(false); // Reset React state flag
confettiParticles = [];
// --- Grid Size Determination ---
if (lvl <= 3) gridSize = 4;
else if (lvl <= 6) gridSize = 5;
else if (lvl <= 9) gridSize = 6;
else if (lvl <= 12) gridSize = 7;
else gridSize = 8; // Cap at 8x8 for now
// Ensure gridSize is reasonable
gridSize = Math.max(3, Math.min(gridSize, 10)); // Example bounds
cellSize = p.width / gridSize;
// --- Reset Core Game State ---
grid = Array(gridSize).fill(null).map(() => Array(gridSize).fill(null));
pairs = []; // Reset confirmed pairs
paths = {}; // Reset player paths
isDrawing = false;
currentDrawingColor = null;
currentDrawingColorKey = null;
startNode = null;
// --- Determine Number of Pairs ---
// Adjust density based on grid size - larger grids can handle relatively more pairs
// Keep density fairly low to increase solvability chances. ~15-20% seems reasonable.
let numPairs = p.floor(gridSize * gridSize * 0.18);
numPairs = p.max(2, p.min(numPairs, availableColors.length)); // Ensure 2 <= numPairs <= availableColors
// --- Solvability Helper Functions (BFS and Crossing Check) ---
// (Keep your existing bfs, wouldPathsCross, and isValidNewPath functions here)
// Make sure isValidNewPath correctly checks against ALL segments of existingPaths
const colorToString = (p5Color: p5.Color | null): string => {
if (!p5Color) return 'null';
// Use p.red, p.green, p.blue to get components if needed, ensure consistent string format
return `rgba(${p.red(p5Color)},${p.green(p5Color)},${p.blue(p5Color)},${p.alpha(p5Color)})`;
};
const wouldPathsCross = (path1: Array<{r:number,c:number}>, path2: Array<{r:number,c:number}>): boolean => {
// Check each segment of path1 against each segment of path2
for (let i = 0; i < path1.length - 1; i++) {
const a1 = path1[i];
const a2 = path1[i + 1];
for (let j = 0; j < path2.length - 1; j++) {
const b1 = path2[j];
const b2 = path2[j + 1];
// Skip if segments share an endpoint
if ((a1.r === b1.r && a1.c === b1.c) || (a1.r === b2.r && a1.c === b2.c) ||
(a2.r === b1.r && a2.c === b1.c) || (a2.r === b2.r && a2.c === b2.c)) {
continue;
}
// Skip if segments are parallel and not overlapping
const isHorizontal1 = a1.r === a2.r;
const isHorizontal2 = b1.r === b2.r;
const isVertical1 = a1.c === a2.c;
const isVertical2 = b1.c === b2.c;
if ((isHorizontal1 && isHorizontal2) || (isVertical1 && isVertical2)) {
continue;
}
// Check if segments intersect
// Convert to line segments and check intersection
const ccw = (A: {r:number,c:number}, B: {r:number,c:number}, C: {r:number,c:number}): boolean => {
return (C.c - A.c) * (B.r - A.r) > (B.c - A.c) * (C.r - A.r);
};
if (ccw(a1, b1, b2) !== ccw(a2, b1, b2) && ccw(a1, a2, b1) !== ccw(a1, a2, b2)) {
return true; // Segments intersect
}
}
}
return false;
};
const isValidNewPath = (newPath: Array<{r:number,c:number}>, existingPaths: Array<Array<{r:number,c:number}>>): boolean => {
// Ensure newPath is valid and has at least 2 points
if (!newPath || newPath.length < 2) return true; // Or false, depending on desired handling
// Check against all segments of all existing paths
return !existingPaths.some(existingPath => {
if (!existingPath || existingPath.length < 2) return false;
return wouldPathsCross(newPath, existingPath);
});
};
const bfs = (start: {r:number,c:number}, end: {r:number,c:number}, occupied: Set<string>, existingPaths: Array<Array<{r:number,c:number}>>): Array<{r:number,c:number}>|null => {
const queue: Array<Array<{r:number,c:number}>> = [[start]];
const visited = new Set<string>([`${start.r},${start.c}`]);
const endKey = `${end.r},${end.c}`;
while (queue.length) {
const currentPath = queue.shift()!;
const {r, c} = currentPath[currentPath.length-1];
// Found the end point
if (r === end.r && c === end.c) return currentPath;
// Try all four directions (neighbors)
for (const d of [[0,1],[1,0],[0,-1],[-1,0]]) {
const nr = r + d[0], nc = c + d[1];
const key = `${nr},${nc}`;
// Skip out of bounds
if (nr < 0 || nr >= gridSize || nc < 0 || nc >= gridSize) continue;
// Skip visited cells
if (visited.has(key)) continue;
// Skip cells occupied by other paths (intermediate points only)
// Allow endpoint even if occupied (it's the target)
if (key !== endKey && occupied.has(key)) continue;
// Critical Check: Would moving to this neighbor cross an existing path?
const potentialNextSegment = [currentPath[currentPath.length-1], {r:nr, c:nc}];
if (!isValidNewPath(potentialNextSegment, existingPaths)) continue;
visited.add(key);
queue.push([...currentPath, {r:nr,c:nc}]);
}
}
return null; // No path found
};
// --- Generation Loop with Solvability Guarantee ---
let maxGenerationAttempts = 500; // Max attempts to generate a single solvable level
let foundSolvableLevel = false;
while (!foundSolvableLevel && maxGenerationAttempts-- > 0) {
// 1. Reset attempt-specific state
const attemptPairs: typeof pairs = []; // Changed let to const
const occupiedEndpoints = new Set<string>();
grid = Array(gridSize).fill(null).map(() => Array(gridSize).fill(null)); // Reset grid for placement
// 2. Place Pair Endpoints Randomly
const allCells = [];
for (let r = 0; r < gridSize; r++) for (let c = 0; c < gridSize; c++) allCells.push({ r, c });
allCells.sort(() => Math.random() - 0.5); // Shuffle cells
// Assign endpoints ensuring unique colors
const usedColors = new Set<string>();
for (let i = 0; i < numPairs; i++) {
if (allCells.length < 2) break; // Need 2 cells per pair
const startPos = allCells.shift()!;
const endPos = allCells.shift()!;
const pairColor = availableColors[i % availableColors.length]; // Cycle through colors
const colorKey = colorToString(pairColor);
// Basic check for duplicate colors (shouldn't happen with index % length)
if (usedColors.has(colorKey)) {
console.warn("Duplicate color selected, skipping pair generation.");
// Add cells back if needed, or just break/continue
allCells.push(startPos, endPos); // Put cells back
allCells.sort(() => Math.random() - 0.5); // Re-shuffle
i--; // Retry assigning this pair index
continue;
}
usedColors.add(colorKey);
attemptPairs.push({
color: pairColor,
start: startPos,
end: endPos,
connected: false
});
occupiedEndpoints.add(`${startPos.r},${startPos.c}`);
occupiedEndpoints.add(`${endPos.r},${endPos.c}`);
// Mark grid temporarily for visualization or debugging if needed
// grid[startPos.r][startPos.c] = { type: 'temp', color: pairColor };
// grid[endPos.r][endPos.c] = { type: 'temp', color: pairColor };
}
if (attemptPairs.length !== numPairs) continue; // Failed to place enough pairs
// 3. Verify Solvability (The CRITICAL Step)
let isPlacementSolvable = false;
let solutionPathsForAttempt: Record<string, Array<{r:number,c:number}>> = {};
// We need to find if there EXISTS at least one ordering of pairs
// such that all pairs can be connected sequentially without crossing.
// Trying different orders is a heuristic approach.
const pairOrderPermutations = [ // Add more permutations if needed
[...attemptPairs], // Original
[...attemptPairs].sort(() => Math.random() - 0.5), // Random 1
[...attemptPairs].sort(() => Math.random() - 0.5), // Random 2
// Sort by Manhattan distance (Shortest first)
[...attemptPairs].sort((a, b) => (Math.abs(a.start.r - a.end.r) + Math.abs(a.start.c - a.end.c)) - (Math.abs(b.start.r - b.end.r) + Math.abs(b.start.c - b.end.c))),
// Sort by Manhattan distance (Longest first)
[...attemptPairs].sort((a, b) => (Math.abs(b.start.r - b.end.r) + Math.abs(b.start.c - b.end.c)) - (Math.abs(a.start.r - a.end.r) + Math.abs(a.start.c - a.end.c))),
];
for (const currentOrder of pairOrderPermutations) {
const pathOccupiedCells = new Set<string>(); // Cells blocked by paths in *this* order attempt
//NOTE: Originally included occupiedEndpoints, but BFS handles start/end blocking. Only block intermediate path cells.
const currentGeometryPaths: Array<Array<{r:number,c:number}>> = []; // Path geometries for crossing checks in *this* order
let allPathsSuccessfullyRouted = true;
solutionPathsForAttempt = {}; // Reset paths for this order
for (const pair of currentOrder) {
const { start, end, color } = pair;
const colorKey = colorToString(color);
// Use BFS to find a path, considering currently occupied path cells AND potential crossings
const path = bfs(start, end, pathOccupiedCells, currentGeometryPaths);
if (!path) {
allPathsSuccessfullyRouted = false; // Cannot route this pair in this order
break; // Try the next permutation
}
// Path found: Record it and update occupied state for the *next* pair in this sequence
solutionPathsForAttempt[colorKey] = path;
currentGeometryPaths.push(path);
// Mark intermediate path cells as occupied
for (let k = 1; k < path.length - 1; k++) {
pathOccupiedCells.add(`${path[k].r},${path[k].c}`);
}
}
if (allPathsSuccessfullyRouted) {
isPlacementSolvable = true; // Found a valid routing order!
break; // No need to check other permutations
}
} // End checking permutations
// 4. Commit if Solvable
if (isPlacementSolvable) {
console.log(`Successfully generated solvable level ${lvl} (Grid: ${gridSize}x${gridSize}, Pairs: ${attemptPairs.length})`);
pairs = attemptPairs; // Commit the valid pair placement
paths = {}; // Reset runtime paths for the player
// Final grid setup with actual tequila points
grid = Array(gridSize).fill(null).map(() => Array(gridSize).fill(null)); // Clear grid again
for (const pair of pairs) {
const { start, end, color } = pair;
grid[start.r][start.c] = { type: 'tequila', color, isStart: true };
grid[end.r][end.c] = { type: 'tequila', color, isStart: false };
paths[colorToString(color)] = []; // Initialize empty path for player
}
foundSolvableLevel = true; // Exit the main generation loop
} else {
// Optional: Log failed attempt for debugging
// console.log(`Level ${lvl} generation attempt failed, retrying... (${maxGenerationAttempts} left)`);
}
} // End while (!foundSolvableLevel)
// --- Fallback if Generation Failed ---
if (!foundSolvableLevel) {
console.error(`Failed to generate a solvable level ${lvl} after ${500} attempts.`);
// Implement a fallback:
// 1. Show an error message to the user.
// 2. Generate a known simple, solvable level.
// 3. Retry with fewer pairs or smaller grid temporarily.
alert(`Error: Could not generate a solvable level ${lvl}. Please try refreshing or contact support.`);
// Example fallback: Generate a minimal 4x4 level
if (gridSize >= 4 && availableColors.length >= 2) {
gridSize = 4;
cellSize = p.width / gridSize;
grid = Array(gridSize).fill(null).map(() => Array(gridSize).fill(null));
pairs = [
{ color: availableColors[0], start: { r: 0, c: 0 }, end: { r: 3, c: 0 }, connected: false },
{ color: availableColors[1], start: { r: 0, c: 3 }, end: { r: 3, c: 3 }, connected: false }
];
paths = {};
for (const pair of pairs) {
grid[pair.start.r][pair.start.c] = { type: 'tequila', color: pair.color, isStart: true };
grid[pair.end.r][pair.end.c] = { type: 'tequila', color: pair.color, isStart: false };
paths[colorToString(pair.color)] = [];
}
console.warn(`Using fallback level for level ${lvl}.`);
} else {
// Cannot even generate fallback
console.error(`CRITICAL ERROR: Cannot generate fallback level for level ${lvl}.`);
alert('Critical Error: Unable to generate even a basic level. Please contact support.');
// Potentially disable the game or redirect
}
}
}; // End setupLevel
// --- p5.js Built-in Functions ---
p.setup = () => {
p.createCanvas(canvasSize, canvasSize);
p.pixelDensity(1);
p.colorMode(p.RGB);
p.textAlign(p.CENTER, p.CENTER);
p.textSize(18);
p.textFont('Arial');
// Define the color palette
availableColors = [
p.color(255, 0, 0), // Red
p.color(0, 0, 255), // Blue
p.color(0, 255, 0), // Green
p.color(255, 255, 0), // Yellow
p.color(128, 0, 128), // Purple
p.color(255, 165, 0), // Orange
p.color(0, 255, 255), // Cyan
p.color(255, 0, 255), // Magenta
p.color(255, 192, 203),// Pink
p.color(165, 42, 42), // Brown
];
setupLevel(level); // Initial setup using React's level state
};
p.draw = () => {
p.background(BACKGROUND_COLOR);
// 1. Draw Grid Lines
p.stroke(GRID_LINE_COLOR);
p.strokeWeight(1);
for (let i = 0; i <= gridSize; i++) {
p.line(i * cellSize, 0, i * cellSize, p.height);
p.line(0, i * cellSize, p.width, i * cellSize);
}
// 2. Draw Tequilas
for (const pair of pairs) {
// Only draw if not covered by a completed path endpoint
const startCell = grid[pair.start.r][pair.start.c];
const endCell = grid[pair.end.r][pair.end.c];
if (startCell && startCell.type === 'tequila') {
drawTequilaSprite(pair.start.c * cellSize + cellSize / 2, pair.start.r * cellSize + cellSize / 2, cellSize * 0.7, pair.color);
}
if (endCell && endCell.type === 'tequila') {
drawTequilaSprite(pair.end.c * cellSize + cellSize / 2, pair.end.r * cellSize + cellSize / 2, cellSize * 0.7, pair.color);
}
}
// 3. Draw Paths
p.strokeWeight(cellSize * 0.4);
p.strokeCap(p.ROUND);
p.strokeJoin(p.ROUND);
for (const colorKey in paths) {
const path = paths[colorKey];
if (path.length > 1) {
const pathColor = colorKeyToColor(colorKey);
if (!pathColor) continue;
p.stroke(pathColor);
p.noFill();
p.beginShape();
for (const pt of path) {
p.vertex(pt.c * cellSize + cellSize / 2, pt.r * cellSize + cellSize / 2);
}
p.endShape();
}
}
// 4. Level Complete Overlay & Logic
if (levelCompleteInternal) {
// Draw overlay
p.fill(LEVEL_COMPLETE_OVERLAY);
p.noStroke();
p.rect(0, 0, p.width, p.height);
p.fill(LEVEL_COMPLETE_TEXT);
p.textSize(48);
p.textAlign(p.CENTER, p.CENTER);
p.text(`Level ${currentLevel} Complete!`, p.width / 2, p.height / 2);
p.textSize(18); // Reset text size
}
// Draw confetti
for (const particle of confettiParticles) {
p.push(); p.translate(particle.x, particle.y); p.rotate(particle.rotation);
p.fill(particle.color); p.noStroke(); p.rect(0, 0, particle.size, particle.size * 0.5);
p.pop();
// Update movement
particle.x += particle.vx;
particle.y += particle.vy;
particle.rotation += particle.rotationSpeed;
}
// Check timer to advance level
// Auto-advance removed: user must click Next Level button
};
p.mousePressed = () => {
if (levelCompleteInternal) return;
const gridPos = getGridPosFromMouse(p.mouseX, p.mouseY);
if (!gridPos) return;
const cellContent = grid[gridPos.r][gridPos.c];
if (cellContent && cellContent.type === 'tequila') {
isDrawing = true;
currentDrawingColor = cellContent.color;
currentDrawingColorKey = colorToString(currentDrawingColor);
startNode = gridPos;
clearPath(currentDrawingColorKey); // Clear existing path if restarting
paths[currentDrawingColorKey] = [gridPos];
grid[gridPos.r][gridPos.c] = { type: 'path', color: currentDrawingColor };
}
};
p.mouseDragged = () => {
if (!isDrawing || !currentDrawingColorKey) return;
const gridPos = getGridPosFromMouse(p.mouseX, p.mouseY);
if (!gridPos) return;
const currentPath = paths[currentDrawingColorKey];
if (!currentPath || currentPath.length === 0) { // Safety check
isDrawing = false;
currentDrawingColor = null;
currentDrawingColorKey = null;
startNode = null;
return;
}
const lastPos = currentPath[currentPath.length - 1];
if (gridPos.r === lastPos.r && gridPos.c === lastPos.c) return; // Same cell
if (p.abs(gridPos.r - lastPos.r) + p.abs(gridPos.c - lastPos.c) !== 1) return; // Not adjacent
const targetCellContent = grid[gridPos.r][gridPos.c];
const targetPair = findPairByColor(currentDrawingColor);
// 1. Check for backtracking (erasing)
if (currentPath.length >= 2 && gridPos.r === currentPath[currentPath.length - 2].r && gridPos.c === currentPath[currentPath.length - 2].c) {
const removedPos = currentPath.pop()!;
const originalStartInfo = findPairByColor(currentDrawingColor); // Get pair info
// Check if the removed position was the original start/end point
const isStart = originalStartInfo && removedPos.r === originalStartInfo.start.r && removedPos.c === originalStartInfo.start.c;
const isEnd = originalStartInfo && removedPos.r === originalStartInfo.end.r && removedPos.c === originalStartInfo.end.c;
if (isStart || isEnd) {
// Restore the tequila marker
grid[removedPos.r][removedPos.c] = { type: 'tequila', color: currentDrawingColor!, isStart: isStart };
} else {
// Just clear the path cell
grid[removedPos.r][removedPos.c] = null;
}
return; // Stop after erasing
}
// 2. Check for valid moves
if (targetCellContent === null) {
// Moving into an empty cell
currentPath.push(gridPos);
grid[gridPos.r][gridPos.c] = { type: 'path', color: currentDrawingColor! };
} else if (targetCellContent.type === 'tequila' && targetCellContent.color.toString() === currentDrawingColor!.toString()) {
// Moving onto a tequila of the *same* color
let isCorrectEndpoint = false;
if (startNode && targetPair) {
if (startNode.r === targetPair.start.r && startNode.c === targetPair.start.c) { // Started at 'start'
if (gridPos.r === targetPair.end.r && gridPos.c === targetPair.end.c) isCorrectEndpoint = true;
} else { // Started at 'end'
if (gridPos.r === targetPair.start.r && gridPos.c === targetPair.start.c) isCorrectEndpoint = true;
}
}
if (isCorrectEndpoint && !targetPair!.connected) {
currentPath.push(gridPos);
grid[gridPos.r][gridPos.c] = { type: 'path', color: currentDrawingColor! }; // Mark endpoint as path
targetPair!.connected = true;
isDrawing = false; // Stop drawing this path
currentDrawingColor = null;
currentDrawingColorKey = null;
startNode = null;
checkLevelCompletion();
} else {
// Hit the *same* tequila again or an already connected one - invalid move
// Stop the current path drawing attempt
p.mouseReleased();
}
} else {
// Invalid move: Hit another path, another tequila, or occupied cell
p.mouseReleased(); // Treat as release/cancel
}
};
p.mouseReleased = () => {
if (isDrawing && currentDrawingColorKey) {
const path = paths[currentDrawingColorKey];
const targetColor = colorKeyToColor(currentDrawingColorKey);
const targetPair = findPairByColor(targetColor);
// If path exists but is not connected, clear it
if (path && path.length > 0 && targetPair && !targetPair.connected) {
clearPath(currentDrawingColorKey);
}
}
// Reset drawing state regardless
isDrawing = false;
currentDrawingColor = null;
currentDrawingColorKey = null;
startNode = null;
};
// Receive updates from React state (e.g., when level changes)
(p as P5Instance).updateWithProps = props => {
if (props.level && props.level !== currentLevel) {
setupLevel(props.level);
}
};
}, [canvasSize, setIsLevelComplete]); // Dependencies for the sketch function creation
// --- Effect for p5 Initialization and Cleanup ---
useEffect(() => {
// Create p5 instance
p5InstanceRef.current = new p5(Sketch, sketchRef.current) as P5Instance;
// Cleanup function to remove p5 instance
return () => {
if (p5InstanceRef.current) {
p5InstanceRef.current.remove();
p5InstanceRef.current = null; // Clear the ref
}
};
}, [Sketch]); // Re-create sketch if the Sketch function itself changes
// --- Scoring System: Award Tequilas on Level Complete ---
const computeNumPairs = (lvl: number): number => {
const gs = lvl <= 3 ? 4 : lvl <= 6 ? 5 : lvl <= 9 ? 6 : lvl <= 12 ? 7 : 8;
const raw = Math.floor((gs * gs) / 5);
return Math.max(2, Math.min(raw, 10));
};
useEffect(() => {
if (isLevelComplete) {
const earned = level * computeNumPairs(level);
setTequilas(prev => prev + earned);
}
}, [isLevelComplete, level]); // Run when completion or level changes
// --- Next Level Button Handler ---
const handleNextLevel = () => {
const nextLevel = level + 1;
if (level >= highScore) {
const newHighScore = level + 1;
setHighScore(newHighScore);
localStorage.setItem('tequilaConnectHighScore', newHighScore.toString());
}
setLevel(nextLevel);
setIsLevelComplete(false);
setShowNextButton(false);
// Force p5 to setup next level immediately
if (p5InstanceRef.current?.updateWithProps) {
p5InstanceRef.current.updateWithProps({ level: nextLevel });
}
};
// --- Show Next Button after confetti delay ---
useEffect(() => {
if (isLevelComplete) {
setShowNextButton(false);
const timer = setTimeout(() => setShowNextButton(true), 1100); // 1.1s delay
return () => clearTimeout(timer);
}
}, [isLevelComplete]);
// --- Effect to Pass Updated Level Prop to p5 ---
useEffect(() => {
if (p5InstanceRef.current && p5InstanceRef.current.updateWithProps) {
p5InstanceRef.current.updateWithProps({ level });
}
}, [level]);
// --- Render Component ---
return (
<div className="ctt-container">
<Link to="/" className="ctt-backButton">
<ArrowLeftIcon size={24} />
</Link>
<div className="ctt-infoText">
<span>Level: {level}</span>
<span>Tequilas: {tequilas}</span>
<span>High Score: {highScore}</span>
</div>
{/* Container where the p5 canvas will be rendered */}
<div ref={sketchRef} className="ctt-canvasContainer"></div>
{isLevelComplete && showNextButton && (
<div className="ctt-overlay">
<div className="ctt-modal">
<h2 className="ctt-modalTitle">Level Complete!</h2>
<button
className="ctt-nextButton"
onClick={handleNextLevel}
autoFocus
>
Next Level
</button>
</div>
</div>
)}
</div>
);
};
export default TequilaConnectGame; // Export the component
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment