Trudy's picture
3 dots game
21daab4
<!doctype html>
<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>