Created
April 19, 2025 18:20
-
-
Save altuzar/98e968ae7960ad8aa8d34a14bcc8bb0c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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