Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<meta name="theme-color" content="#000000" /> | |
<meta name="description" content="Web site created using create-react-app" /> | |
<link rel="preconnect" href="https://fonts.googleapis.com" /> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" | |
rel="stylesheet" /> | |
<link | |
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block" | |
rel="stylesheet" /> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.10.2/p5.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.10.2/addons/p5.sound.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/gh/molleindustria/p5.play/lib/p5.play.js"></script> | |
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | |
<!-- | |
Notice the use of %PUBLIC_URL% in the tags above. | |
It will be replaced with the URL of the `public` folder during the build. | |
Only files inside the `public` folder can be referenced from the HTML. | |
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will | |
work correctly both with client-side routing and a non-root public URL. | |
Learn how to configure a non-root public URL by running `npm run build`. | |
--> | |
<title>Multimodal Live - Console</title> | |
</head> | |
<body> | |
<noscript>You need to enable JavaScript to run this app.</noscript> | |
<div id="root"></div> | |
<script> | |
let mySketch; | |
let osc; // oscillator for the tone | |
let freq = 261; // base frequency of the tone | |
let selectedCircleIndex = -1; | |
let currentLevel = 1; | |
let targetCircles = []; | |
let matchThreshold = 20; // threshold for position and size matching | |
let levelComplete = false; | |
let score = 0; | |
let moveCount = 0; // track number of moves in current level | |
let totalMoves = 0; // track total moves across all levels | |
let matchedCircles = []; // track which circles are matched | |
let matchAnimationTime = []; // track animation time for each circle | |
let gameComplete = false; // track if the entire game is complete | |
let gameOver = false; | |
let timeLeft = 30; | |
let timerStarted = false; | |
let timerInterval = null; | |
const FINAL_LEVEL = 5; // number of levels in the game | |
const VIBRANT_COLORS = [ | |
'#FF0000', // Red | |
'#00FF00', // Lime | |
'#0000FF', // Blue | |
'#FF00FF', // Magenta | |
'#00FFFF', // Cyan | |
'#FFD700', // Gold | |
'#FF4500', // OrangeRed | |
'#32CD32', // LimeGreen | |
'#8A2BE2', // BlueViolet | |
'#FF1493' // DeepPink | |
]; | |
// Function to get n unique random colors from the VIBRANT_COLORS array | |
function getRandomColors(n) { | |
const shuffled = [...VIBRANT_COLORS]; | |
for (let i = shuffled.length - 1; i > 0; i--) { | |
const j = Math.floor(Math.random() * (i + 1)); | |
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; | |
} | |
return shuffled.slice(0, n); | |
} | |
function calculateLevelScore() { | |
// Base score for completing level | |
const baseScore = 1000; | |
// Penalty for number of moves (encourage efficiency) | |
const movePenalty = Math.min(moveCount * 10, 500); // cap penalty at 500 | |
return Math.max(baseScore - movePenalty, 100); // minimum 100 points | |
} | |
function isCircleMatched(circle, target) { | |
const maxDistance = target.radius * 0.20; // 5% of target radius for position tolerance | |
const maxSizeDiff = target.radius * 0.20; // 5% of target radius for size tolerance | |
const distX = Math.abs(circle.x - target.x); | |
const distY = Math.abs(circle.y - target.y); | |
const distRadius = Math.abs(circle.radius - target.radius); | |
console.log(`Checking circle ${circle.color}:`); | |
console.log(` Position: (${circle.x.toFixed(2)}, ${circle.y.toFixed(2)}) vs target (${target.x.toFixed(2)}, ${target.y.toFixed(2)})`); | |
console.log(` Distance: X=${distX.toFixed(2)}, Y=${distY.toFixed(2)} (max allowed: ${maxDistance.toFixed(2)})`); | |
console.log(` Radius: ${circle.radius.toFixed(2)} vs ${target.radius.toFixed(2)} (diff: ${distRadius.toFixed(2)}, max allowed: ${maxSizeDiff.toFixed(2)})`); | |
const isMatched = distX <= maxDistance && | |
distY <= maxDistance && | |
distRadius <= maxSizeDiff; | |
console.log(` MATCH: ${isMatched}`); | |
return isMatched; | |
} | |
function startTimer() { | |
if (!timerStarted) { | |
timerStarted = true; | |
timeLeft = 30; | |
timerInterval = setInterval(() => { | |
timeLeft--; | |
if (timeLeft <= 0) { | |
clearInterval(timerInterval); | |
gameOver = true; | |
} | |
}, 1000); | |
} | |
} | |
function resetGame() { | |
currentLevel = 1; | |
score = 0; | |
moveCount = 0; | |
totalMoves = 0; | |
gameComplete = false; | |
gameOver = false; | |
timerStarted = false; | |
timeLeft = 30; | |
if (timerInterval) { | |
clearInterval(timerInterval); | |
} | |
matchedCircles = new Array(currentLevel).fill(false); | |
matchAnimationTime = new Array(currentLevel).fill(0); | |
generateTargets(); | |
} | |
function checkMatch() { | |
console.log("\n=== Checking Matches ==="); | |
// Check each circle individually | |
let allMatched = true; | |
let anyNewMatch = false; | |
for(let i = 0; i < window.circles.length; i++) { | |
const wasMatched = matchedCircles[i]; | |
const newMatch = isCircleMatched(window.circles[i], targetCircles[i]); | |
if (newMatch !== matchedCircles[i]) { | |
console.log(`Circle ${window.circles[i].color} match status changed: ${wasMatched} -> ${newMatch}`); | |
} | |
matchedCircles[i] = newMatch; | |
if (matchedCircles[i] && !wasMatched) { | |
// New match - play a tone and start animation | |
anyNewMatch = true; | |
matchAnimationTime[i] = 0; | |
if (osc) { | |
osc.freq(440 + i * 100); // Different tone for each circle | |
osc.start(); | |
osc.amp(0.2); | |
osc.fade(0, 0.3); | |
} | |
} | |
if (!matchedCircles[i]) { | |
allMatched = false; | |
} | |
} | |
console.log("Current match status:", matchedCircles); | |
console.log("All matched:", allMatched); | |
if(allMatched && !levelComplete) { | |
console.log("*** LEVEL COMPLETE! ***"); | |
levelComplete = true; | |
currentLevel++; | |
score += calculateLevelScore(); | |
totalMoves += moveCount; | |
// Reset timer for next level | |
timerStarted = false; | |
if (timerInterval) { | |
clearInterval(timerInterval); | |
} | |
timeLeft = 30; | |
// Play success sound | |
if (osc) { | |
osc.freq(880); // Higher note for level complete | |
osc.start(); | |
osc.amp(0.3); | |
osc.fade(0, 0.5); | |
} | |
if (currentLevel > FINAL_LEVEL) { | |
gameComplete = true; | |
// Play victory sound | |
if (osc) { | |
osc.freq(1320); // Even higher note for game complete | |
osc.start(); | |
osc.amp(0.4); | |
osc.fade(0, 1.0); | |
} | |
} else { | |
setTimeout(() => { | |
levelComplete = false; | |
generateTargets(); | |
matchedCircles = new Array(currentLevel).fill(false); | |
matchAnimationTime = new Array(currentLevel).fill(0); | |
}, 2000); // Changed to 2 seconds | |
} | |
} | |
} | |
function generateTargets() { | |
// Reset move counter for new level | |
moveCount = 0; | |
matchedCircles = new Array(currentLevel).fill(false); | |
matchAnimationTime = new Array(currentLevel).fill(0); | |
// Calculate the middle 75% of the screen | |
const margin = { | |
x: window.innerWidth * 0.125, // 12.5% margin on each side | |
y: window.innerHeight * 0.125 // 12.5% margin on each side | |
}; | |
const playArea = { | |
width: window.innerWidth * 0.75, // 75% of screen width | |
height: window.innerHeight * 0.75 // 75% of screen height | |
}; | |
// Get random colors for this level | |
const levelColors = getRandomColors(currentLevel); | |
// Generate new random positions for target circles | |
targetCircles = []; | |
window.circles = []; | |
// Add circles based on current level (one more circle per level) | |
for (let i = 0; i < currentLevel; i++) { | |
const color = levelColors[i]; | |
const target = { | |
color: color, | |
x: margin.x + Math.random() * playArea.width, | |
y: margin.y + Math.random() * playArea.height, | |
radius: Math.random() * 50 + 75 | |
}; | |
targetCircles.push(target); | |
// Create corresponding movable circle, positioned in a line in the middle 75% of screen | |
const startX = margin.x + (playArea.width * (i + 1) / (currentLevel + 1)); | |
window.circles.push({ | |
color: color, | |
x: startX, | |
y: window.innerHeight/2, | |
radius: 100 | |
}); | |
} | |
// Update current circles copy | |
window.circlesCurrent = window.circles.map((c) => ({ ...c })); | |
} | |
window.get_circles = function () { | |
return { | |
circles: window.circles, | |
targets: targetCircles, | |
level: currentLevel, | |
isComplete: levelComplete | |
}; | |
}; | |
window.change_circle = function (args) { | |
if (!timerStarted) { | |
startTimer(); | |
} | |
moveCount++; // increment move counter | |
// Play the tone here | |
if (osc) { | |
osc.start(); | |
osc.freq(freq); | |
osc.amp(0.3); | |
osc.fade(0, 0.2); | |
} | |
window.circlesCurrent = window.circles.map((c) => ({ ...c })) | |
const color = args.color; | |
const findIndex = window.circles.findIndex( | |
(c) => c.color.toLowerCase() === color.toLowerCase(), | |
); | |
window.circles.splice(findIndex, 1, args); | |
checkMatch(); | |
}; | |
window.circles = [ | |
{color: "#00FF00", x: window.innerWidth/2, y: window.innerHeight/2, radius: 100}, | |
]; | |
// Generate initial target positions | |
generateTargets(); | |
// make a copy of it | |
window.circlesCurrent = window.circles.map((c) => ({ ...c })) | |
window.initSketch = function (container) { | |
console.log("initialize sketch in public/index.html"); | |
console.log(container); | |
if (mySketch) { | |
return; | |
} | |
mySketch = new p5((p) => { | |
p.setup = function () { | |
console.log(p); | |
p.createCanvas(window.innerWidth, window.innerHeight); | |
container.innerHTMl = ""; | |
container.appendChild(p._renderer.canvas); | |
// Create the oscillator here, after p5.sound has been initialized | |
osc = new p5.Oscillator('sine'); | |
osc.amp(0); // Start with zero amplitude | |
}; | |
function getSelectedCircleIndex() { | |
for (let i = 0; i < circles.length; i++) { | |
const circle = circles[i]; | |
if ( | |
p.mouseX > circle.x - circle.radius && | |
p.mouseX < circle.x + circle.radius && | |
p.mouseY > circle.y - circle.radius && | |
p.mouseY < circle.y + circle.radius | |
) { | |
console.log("clicked : " + i); | |
return i; | |
} | |
} | |
return -1; | |
} | |
p.mousePressed = function () { | |
selectedCircleIndex = getSelectedCircleIndex(); | |
}; | |
p.mouseDragged = function () { | |
if (selectedCircleIndex > -1) { | |
moveCount++; | |
const circle = circles[selectedCircleIndex]; | |
circle.x = p.mouseX; | |
circle.y = p.mouseY; | |
checkMatch(); | |
} | |
}; | |
p.mouseReleased = function () { | |
selectedCircleIndex = -1; | |
}; | |
p.draw = function draw() { | |
p.background(0); | |
p.blendMode(p.BLEND); | |
if (gameComplete || gameOver) { | |
// Draw modal overlay | |
p.background(0, 200); | |
// Create centered content box | |
const boxWidth = 500; | |
const boxHeight = 400; | |
const boxX = p.width/2 - boxWidth/2; | |
const boxY = p.height/2 - boxHeight/2; | |
// Draw box background | |
p.fill(20); | |
p.stroke(255); | |
p.strokeWeight(2); | |
p.rect(boxX, boxY, boxWidth, boxHeight, 20); | |
// Draw content | |
p.fill(255); | |
p.noStroke(); | |
p.textFont('Space Mono'); | |
p.textAlign(p.CENTER, p.CENTER); | |
// Title | |
p.textSize(48); | |
p.text(gameOver ? 'Time\'s Up!' : 'Game Complete!', p.width/2, boxY + 80); | |
if (!gameOver) { | |
// Game complete stats | |
p.textSize(24); | |
p.fill(200); | |
p.text(`Final Score: ${score}`, p.width/2, boxY + 160); | |
p.text(`Total Moves: ${totalMoves}`, p.width/2, boxY + 200); | |
p.text(`Average Moves Per Level: ${(totalMoves/FINAL_LEVEL).toFixed(1)}`, p.width/2, boxY + 240); | |
// Message | |
p.textSize(18); | |
p.fill(150); | |
const efficiency = totalMoves < FINAL_LEVEL * 10 ? "Amazing efficiency!" : | |
totalMoves < FINAL_LEVEL * 20 ? "Great job!" : | |
"Well done!"; | |
p.text(efficiency, p.width/2, boxY + 280); | |
} else { | |
// Game over message | |
p.textSize(24); | |
p.fill(200); | |
p.text(`Total Levels Completed: ${currentLevel - 1}`, p.width/2, boxY + 160); | |
p.text(`Total Moves Made: ${totalMoves + moveCount}`, p.width/2, boxY + 200); | |
p.text(`Circles Matched: ${matchedCircles.filter(m => m).length}/${currentLevel}`, p.width/2, boxY + 240); | |
p.textSize(18); | |
p.fill(150); | |
p.text('Keep trying! You can do it!', p.width/2, boxY + 280); | |
} | |
// Draw try again/play again button | |
const buttonWidth = 200; | |
const buttonHeight = 50; | |
const buttonX = p.width/2 - buttonWidth/2; | |
const buttonY = boxY + boxHeight - 80; | |
// Check if mouse is over button | |
const mouseOverButton = p.mouseX > buttonX && p.mouseX < buttonX + buttonWidth && | |
p.mouseY > buttonY && p.mouseY < buttonY + buttonHeight; | |
// Draw button | |
p.fill(mouseOverButton ? 40 : 30); | |
p.stroke(255); | |
p.strokeWeight(2); | |
p.rect(buttonX, buttonY, buttonWidth, buttonHeight, 10); | |
// Button text | |
p.fill(255); | |
p.noStroke(); | |
p.textSize(24); | |
p.text(gameOver ? 'Try Again' : 'Play Again', p.width/2, buttonY + buttonHeight/2); | |
// Add click handler for button | |
if (mouseOverButton && p.mouseIsPressed) { | |
resetGame(); | |
} | |
return; // Don't draw the rest of the game | |
} | |
// Draw target circles first (semi-transparent) | |
for(let i = 0; i < targetCircles.length; i++) { | |
const target = targetCircles[i]; | |
p.noStroke(); | |
p.blendMode(p.SCREEN); | |
const targetColor = p.color(target.color); | |
targetColor.setAlpha(50); | |
p.fill(targetColor); | |
p.circle(target.x, target.y, target.radius * 2); | |
} | |
// Update animation times | |
for(let i = 0; i < matchAnimationTime.length; i++) { | |
if (matchedCircles[i]) { | |
matchAnimationTime[i] = (matchAnimationTime[i] + 0.1) % (Math.PI * 2); | |
} | |
} | |
// Draw the movable circles | |
for(let i = 0; i < window.circles.length; i++) { | |
const circle = window.circles[i]; | |
p.noStroke(); | |
p.blendMode(p.SCREEN); | |
p.fill(circle.color); | |
// ease towards the final position | |
const easing = 0.15; | |
circlesCurrent[i].x = circlesCurrent[i].x + (circle.x - circlesCurrent[i].x) * easing; | |
circlesCurrent[i].y = circlesCurrent[i].y + (circle.y - circlesCurrent[i].y) * easing; | |
circlesCurrent[i].radius = circlesCurrent[i].radius + (circle.radius - circlesCurrent[i].radius) * easing; | |
// If circle is matched, add white border and pulse animation | |
if (matchedCircles[i]) { | |
const pulseAmount = Math.sin(matchAnimationTime[i]) * 5; | |
p.stroke(255); | |
p.strokeWeight(2); | |
p.circle(circlesCurrent[i].x, circlesCurrent[i].y, (circlesCurrent[i].radius * 2) + pulseAmount); | |
p.noStroke(); | |
p.circle(circlesCurrent[i].x, circlesCurrent[i].y, circlesCurrent[i].radius * 2); | |
} else { | |
p.circle(circlesCurrent[i].x, circlesCurrent[i].y, circlesCurrent[i].radius * 2); | |
} | |
} | |
// Draw UI elements with BLEND mode | |
p.blendMode(p.BLEND); | |
// Draw dark semi-transparent background for UI | |
p.noStroke(); | |
p.fill(0, 180); | |
p.rect(0, 20, p.width, 100); | |
// Draw level and timer | |
p.fill(255); | |
p.textFont('Space Mono'); | |
p.textSize(24); | |
p.textAlign(p.CENTER, p.TOP); | |
p.text(`Level: ${window.circles.length} | Time Left: ${timeLeft}s`, p.width/2, 40); | |
// Draw instructions | |
p.textSize(14); | |
p.fill(180); | |
p.text('Speak to Gemini to move the circle to their place', p.width/2, 75); | |
if(levelComplete) { | |
p.textSize(32); | |
p.textAlign(p.CENTER, p.CENTER); | |
p.fill(255); | |
p.text('Level Complete!', p.width/2, 170); | |
} | |
}; | |
}); | |
}; | |
</script> | |
</body> | |
</html> | |