Garden Match
Match 3 to crush. Match 4/5 for powerups.
Out of Moves!
Score: 0
`;
this.canvas = this.shadow.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.scoreDisplay = this.shadow.getElementById('score-display');
this.movesDisplay = this.shadow.getElementById('moves-display');
this.startScreen = this.shadow.getElementById('start-screen');
this.gameOverScreen = this.shadow.getElementById('game-over-screen');
}
setupEvents() {
this.handleResize = () => this.initCanvas();
window.addEventListener('resize', this.handleResize);
const bindTouch = (el, type, handler, passive) => {
el.addEventListener(type, handler.bind(this), { passive: passive });
};
// Canvas Inputs
bindTouch(this.canvas, 'touchstart', this.handleInputDown, false);
bindTouch(this.canvas, 'touchmove', this.handleInputMove, false);
bindTouch(this.canvas, 'touchend', this.handleInputUp, false);
bindTouch(this.canvas, 'mousedown', this.handleInputDown, false);
bindTouch(this.canvas, 'mousemove', this.handleInputMove, false);
bindTouch(this.canvas, 'mouseup', this.handleInputUp, false);
// Buttons
this.shadow.getElementById('btn-start').addEventListener('click', () => {
this.initAudio();
this.startScreen.classList.remove('active');
this.currentState = this.STATE.IDLE;
});
this.shadow.getElementById('btn-play-again').addEventListener('click', () => {
this.resetGame();
this.gameOverScreen.classList.remove('active');
});
this.shadow.getElementById('btn-restart').addEventListener('click', () => {
this.resetGame();
});
this.shadow.getElementById('btn-mute').addEventListener('click', (e) => {
this.isMuted = !this.isMuted;
e.target.innerText = this.isMuted ? '🔈 Unmute' : '🔊 Mute';
});
}
initCanvas() {
const rect = this.canvas.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr);
this.canvasWidth = rect.width;
this.canvasHeight = rect.height;
// Grid sizing
const padding = 20;
this.boardSize = Math.min(this.canvasWidth, this.canvasHeight) - padding * 2;
this.tileSize = this.boardSize / this.COLS;
this.boardX = (this.canvasWidth - this.boardSize) / 2;
this.boardY = (this.canvasHeight - this.boardSize) / 2;
// Reposition tiles on resize
for (let r = 0; r < this.ROWS; r++) {
for (let c = 0; c < this.COLS; c++) {
if (this.grid[r] && this.grid[r][c]) {
this.grid[r][c].visX = c * this.tileSize;
this.grid[r][c].visY = r * this.tileSize;
}
}
}
}
initBackground() {
this.floatingPetals = [];
for(let i=0; i<30; i++) {
this.floatingPetals.push({
x: Math.random() * 1000, y: Math.random() * 1000,
s: Math.random() * 0.5 + 0.5, a: Math.random() * Math.PI * 2,
va: (Math.random() - 0.5) * 0.05,
vy: Math.random() * 20 + 10,
type: Math.floor(Math.random() * 3)
});
}
}
createTile(r, c, type, special = this.SPECIAL_NONE) {
return {
r, c,
type, special,
visX: c * this.tileSize,
visY: -this.boardSize + r * this.tileSize, // Start above board
scale: 1, alpha: 1, isDead: false
};
}
initBoard() {
this.grid = Array.from({ length: this.ROWS }, () => new Array(this.COLS).fill(null));
for (let r = 0; r < this.ROWS; r++) {
for (let c = 0; c < this.COLS; c++) {
let type;
do {
type = Math.floor(Math.random() * this.TYPES);
} while (
(c >= 2 && this.grid[r][c-1].type === type && this.grid[r][c-2].type === type) ||
(r >= 2 && this.grid[r-1][c].type === type && this.grid[r-2][c].type === type)
);
this.grid[r][c] = this.createTile(r, c, type);
}
}
// Ensure valid moves exist
if(!this.hasValidMove()) this.initBoard();
this.currentState = this.STATE.ANIMATING; // Initial fall animation
}
resetGame() {
this.score = 0;
this.moves = 30;
this.updateUI();
this.initBoard();
this.currentState = this.STATE.ANIMATING;
this.gameOverScreen.classList.remove('active');
}
// --- AUDIO ENGINE ---
initAudio() {
if (!this.audioCtx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioCtx = new AudioContext();
}
if (this.audioCtx.state === 'suspended') this.audioCtx.resume();
}
playSound(type) {
if(this.isMuted || !this.audioCtx) return;
const osc = this.audioCtx.createOscillator();
const gainNode = this.audioCtx.createGain();
osc.connect(gainNode);
gainNode.connect(this.audioCtx.destination);
const now = this.audioCtx.currentTime;
if (type === 'swap') {
osc.type = 'sine';
osc.frequency.setValueAtTime(400, now);
osc.frequency.exponentialRampToValueAtTime(600, now + 0.1);
gainNode.gain.setValueAtTime(0.3, now);
gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start(now); osc.stop(now + 0.1);
} else if (type === 'match') {
osc.type = 'triangle';
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(1200, now + 0.2);
gainNode.gain.setValueAtTime(0.3, now);
gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
} else if (type === 'special') {
osc.type = 'square';
osc.frequency.setValueAtTime(300, now);
osc.frequency.linearRampToValueAtTime(800, now + 0.3);
gainNode.gain.setValueAtTime(0.4, now);
gainNode.gain.linearRampToValueAtTime(0.01, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
}
}
// --- INPUT HANDLING ---
getGridPos(x, y) {
const col = Math.floor((x - this.boardX) / this.tileSize);
const row = Math.floor((y - this.boardY) / this.tileSize);
if(col >= 0 && col < this.COLS && row >= 0 && row < this.ROWS) return {r: row, c: col};
return null;
}
handleInputDown(e) {
if (e.type.includes('touch')) e.preventDefault(); // Scroll lock
if (this.currentState !== this.STATE.IDLE) return;
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const x = clientX - rect.left;
const y = clientY - rect.top;
const pos = this.getGridPos(x, y);
if (pos) {
this.dragStart = { x, y, r: pos.r, c: pos.c };
this.selectedTile = this.grid[pos.r][pos.c];
this.selectedTile.scale = 0.85; // Squash
}
}
handleInputMove(e) {
if (e.type.includes('touch')) e.preventDefault();
if (this.currentState !== this.STATE.IDLE || !this.dragStart) return;
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const x = clientX - rect.left;
const y = clientY - rect.top;
const dx = x - this.dragStart.x;
const dy = y - this.dragStart.y;
const threshold = this.tileSize / 3;
if (Math.abs(dx) > threshold || Math.abs(dy) > threshold) {
let tr = this.dragStart.r;
let tc = this.dragStart.c;
if (Math.abs(dx) > Math.abs(dy)) {
tc += dx > 0 ? 1 : -1;
} else {
tr += dy > 0 ? 1 : -1;
}
if (tr >= 0 && tr < this.ROWS && tc >= 0 && tc < this.COLS) {
this.swapTarget = this.grid[tr][tc];
this.attemptSwap();
}
this.dragStart = null; // Prevent multi-triggers
}
}
handleInputUp(e) {
if (e.type.includes('touch')) e.preventDefault();
if (this.selectedTile) {
this.selectedTile.scale = 1.0; // Restore squash
this.selectedTile = null;
}
this.dragStart = null;
}
// --- LOGIC ---
attemptSwap() {
this.currentState = this.STATE.SWAPPING;
this.playSound('swap');
const t1 = this.selectedTile;
const t2 = this.swapTarget;
// Logical swap
this.grid[t1.r][t1.c] = t2;
this.grid[t2.r][t2.c] = t1;
const tempR = t1.r, tempC = t1.c;
t1.r = t2.r; t1.c = t2.c;
t2.r = tempR; t2.c = tempC;
// We defer match checking until visual tween finishes (in gameLoop)
this.selectedTile.scale = 1.0;
this.selectedTile = null;
this.moves--;
this.updateUI();
}
revertSwap() {
this.playSound('swap');
const t1 = this.swapTarget;
const t2 = this.grid[this.swapTarget.r][this.swapTarget.c]; // Was original selected
// Swap back
const temp = this.grid[t1.r][t1.c];
this.grid[t1.r][t1.c] = this.grid[t2.r][t2.c];
this.grid[t2.r][t2.c] = temp;
const tr = t1.r, tc = t1.c;
t1.r = t2.r; t1.c = t2.c;
t2.r = tr; t2.c = tc;
this.swapTarget = null;
this.moves++; // Refund move
this.updateUI();
}
findMatches() {
let matchedSet = new Set();
let powerups = [];
// Check Bombs (if swapped tile was a bomb)
// For simplicity in this logic structure, bombs explode on swap in a real game,
// Here we check if the recent swap target was a bomb and trigger it.
if(this.swapTarget && this.swapTarget.special === this.SPECIAL_BOMB) {
let targetColor = this.grid[this.swapTarget.r][this.swapTarget.c].type; // The other tile
for(let r=0; r
= 5) powerups.push({tile: matchGroup[0], spec: this.SPECIAL_BOMB});
matchGroup.forEach(t => matchedSet.add(t));
c = c2 - 1; // Skip ahead
}
}
}
// Vertical
for (let c = 0; c < this.COLS; c++) {
for (let r = 0; r < this.ROWS - 2; r++) {
let t1 = this.grid[r][c], t2 = this.grid[r+1][c], t3 = this.grid[r+2][c];
if (t1 && t2 && t3 && t1.type === t2.type && t1.type === t3.type && !t1.isDead) {
let matchGroup = [t1, t2, t3];
let r2 = r + 3;
while (r2 < this.ROWS && this.grid[r2][c] && this.grid[r2][c].type === t1.type) {
matchGroup.push(this.grid[r2][c]);
r2++;
}
if (matchGroup.length === 4) powerups.push({tile: matchGroup[0], spec: this.SPECIAL_VERT});
if (matchGroup.length >= 5 && !powerups.find(p=>p.tile===matchGroup[0])) powerups.push({tile: matchGroup[0], spec: this.SPECIAL_BOMB});
matchGroup.forEach(t => matchedSet.add(t));
r = r2 - 1;
}
}
}
// Process special lines destroyed
let arrayMatches = Array.from(matchedSet);
for(let i=0; i 0) {
if(matches.length >= 4) this.shakeTime = 0.2; // Screen shake
this.playSound(powerups.length > 0 ? 'special' : 'match');
matches.forEach(t => {
t.isDead = true;
this.score += 10;
this.spawnParticles(t.visX + this.boardX + this.tileSize/2, t.visY + this.boardY + this.tileSize/2, t.type);
});
// Spawn powerups
powerups.forEach(p => {
p.tile.isDead = false; // Revive it as a powerup
p.tile.special = p.spec;
});
this.updateUI();
// Logical Gravity
for (let c = 0; c < this.COLS; c++) {
let emptySpots = 0;
for (let r = this.ROWS - 1; r >= 0; r--) {
if (this.grid[r][c] && this.grid[r][c].isDead) {
emptySpots++;
this.grid[r][c] = null;
} else if (emptySpots > 0 && this.grid[r][c]) {
let tile = this.grid[r][c];
this.grid[r + emptySpots][c] = tile;
tile.r = r + emptySpots;
this.grid[r][c] = null;
}
}
// Create new tiles
for (let i = 0; i < emptySpots; i++) {
this.grid[i][c] = this.createTile(i, c, Math.floor(Math.random() * this.TYPES));
this.grid[i][c].visY = -(emptySpots - i) * this.tileSize; // Spawn high
}
}
this.currentState = this.STATE.ANIMATING;
this.swapTarget = null;
} else {
// No matches
if (this.swapTarget) {
// Was a user swap that failed
this.revertSwap();
this.currentState = this.STATE.SWAPPING; // Wait for revert animation
} else {
// Cascade finished
this.currentState = this.STATE.IDLE;
if(this.moves <= 0) {
this.currentState = this.STATE.GAMEOVER;
this.shadow.getElementById('final-score').innerText = `Score: ${this.score}`;
this.gameOverScreen.classList.add('active');
} else if (!this.hasValidMove()) {
this.initBoard(); // Reshuffle if strictly stuck
}
}
}
}
hasValidMove() {
for (let r = 0; r < this.ROWS; r++) {
for (let c = 0; c < this.COLS; c++) {
// Check swap right
if (c < this.COLS - 1) {
this.testSwap(r, c, r, c + 1);
if (this.findMatches().matches.length > 0) { this.testSwap(r, c, r, c + 1); return true; }
this.testSwap(r, c, r, c + 1);
}
// Check swap down
if (r < this.ROWS - 1) {
this.testSwap(r, c, r + 1, c);
if (this.findMatches().matches.length > 0) { this.testSwap(r, c, r + 1, c); return true; }
this.testSwap(r, c, r + 1, c);
}
}
}
return false;
}
testSwap(r1, c1, r2, c2) {
let t1 = this.grid[r1][c1];
let t2 = this.grid[r2][c2];
this.grid[r1][c1] = t2;
this.grid[r2][c2] = t1;
if(t1) { t1.r = r2; t1.c = c2; }
if(t2) { t2.r = r1; t2.c = c1; }
}
spawnParticles(x, y, type) {
const colors = ['#ff4d4d', '#4da6ff', '#ffcc00', '#c653c6', '#33cc33', '#ff9933'];
for(let i=0; i<8; i++) {
this.particles.push({
x: x, y: y,
vx: (Math.random() - 0.5) * 400,
vy: (Math.random() - 0.5) * 400 - 100,
life: 1.0, color: colors[type]
});
}
}
updateUI() {
this.scoreDisplay.innerText = this.score;
this.movesDisplay.innerText = this.moves;
}
// --- RENDER ENGINE ---
drawCandy(ctx, x, y, size, type, special, scale, alpha) {
ctx.save();
ctx.translate(x + size/2, y + size/2);
ctx.scale(scale, scale);
ctx.globalAlpha = alpha;
const s = size * 0.4;
// Base shadow
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 10;
ctx.shadowOffsetY = 4;
ctx.beginPath();
let grad = ctx.createRadialGradient(-s*0.3, -s*0.3, s*0.1, 0, 0, s);
switch(type) {
case 0: // Rose (Red Circle)
grad.addColorStop(0, '#ff9999'); grad.addColorStop(1, '#cc0000');
ctx.arc(0, 0, s, 0, Math.PI*2);
break;
case 1: // Lily (Blue Teardrop)
grad.addColorStop(0, '#99ccff'); grad.addColorStop(1, '#0055cc');
ctx.moveTo(0, -s); ctx.quadraticCurveTo(s, 0, 0, s); ctx.quadraticCurveTo(-s, 0, 0, -s);
break;
case 2: // Sunflower (Yellow Polygon)
grad.addColorStop(0, '#ffff99'); grad.addColorStop(1, '#cca300');
for(let i=0; i<8; i++) {
let a = (i/8)*Math.PI*2;
let r = (i%2===0)? s : s*0.6;
ctx.lineTo(Math.cos(a)*r, Math.sin(a)*r);
}
break;
case 3: // Violet (Purple rounded rect)
grad.addColorStop(0, '#e699ff'); grad.addColorStop(1, '#8600b3');
ctx.roundRect(-s*0.8, -s*0.8, s*1.6, s*1.6, s*0.4);
break;
case 4: // Leaf (Green Diamond)
grad.addColorStop(0, '#99ff99'); grad.addColorStop(1, '#009900');
ctx.moveTo(0, -s); ctx.lineTo(s*0.8, 0); ctx.lineTo(0, s); ctx.lineTo(-s*0.8, 0);
break;
case 5: // Marigold (Orange Hex)
grad.addColorStop(0, '#ffcc99'); grad.addColorStop(1, '#cc6600');
for(let i=0; i<6; i++) {
let a = (i/6)*Math.PI*2;
ctx.lineTo(Math.cos(a)*s, Math.sin(a)*s);
}
break;
}
ctx.fillStyle = grad;
ctx.fill();
// Specular Highlight (Premium Glassmorphism)
ctx.shadowColor = 'transparent';
ctx.beginPath();
ctx.ellipse(-s*0.3, -s*0.3, s*0.3, s*0.15, Math.PI/4, 0, Math.PI*2);
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fill();
// Powerup overlays
if(special !== this.SPECIAL_NONE) {
ctx.globalCompositeOperation = 'lighter';
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.beginPath();
if(special === this.SPECIAL_HORZ) { ctx.moveTo(-s, 0); ctx.lineTo(s, 0); }
else if(special === this.SPECIAL_VERT) { ctx.moveTo(0, -s); ctx.lineTo(0, s); }
else if(special === this.SPECIAL_BOMB) { ctx.arc(0,0, s*0.6, 0, Math.PI*2); ctx.strokeStyle='rainbow'; }
ctx.stroke();
}
ctx.restore();
}
draw() {
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
// Screen Shake
this.ctx.save();
if(this.shakeTime > 0) {
let mag = this.shakeTime * 20;
this.ctx.translate((Math.random()-0.5)*mag, (Math.random()-0.5)*mag);
}
// Parallax Background
let bgGrad = this.ctx.createLinearGradient(0,0, 0, this.canvasHeight);
bgGrad.addColorStop(0, '#c2e5d3'); bgGrad.addColorStop(1, '#90cdae');
this.ctx.fillStyle = bgGrad;
this.ctx.fillRect(0,0, this.canvasWidth, this.canvasHeight);
// Floating Petals
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
this.floatingPetals.forEach(p => {
this.ctx.save();
this.ctx.translate(p.x, p.y);
this.ctx.rotate(p.a);
this.ctx.scale(p.s, p.s);
this.ctx.beginPath();
this.ctx.ellipse(0,0, 10, 5, 0, 0, Math.PI*2);
this.ctx.fill();
this.ctx.restore();
});
// Board Base (Frosted Glass 3D Effect)
this.ctx.save();
this.ctx.translate(this.boardX, this.boardY);
this.ctx.shadowColor = 'rgba(0,0,0,0.2)';
this.ctx.shadowBlur = 20;
this.ctx.fillStyle = 'rgba(255,255,255,0.3)';
this.ctx.roundRect(0, 0, this.boardSize, this.boardSize, 16);
this.ctx.fill();
// Grid Cells
this.ctx.strokeStyle = 'rgba(255,255,255,0.2)';
this.ctx.lineWidth = 2;
for (let r = 0; r < this.ROWS; r++) {
for (let c = 0; c < this.COLS; c++) {
this.ctx.strokeRect(c * this.tileSize, r * this.tileSize, this.tileSize, this.tileSize);
}
}
// Tiles
for (let r = 0; r < this.ROWS; r++) {
for (let c = 0; c < this.COLS; c++) {
let tile = this.grid[r][c];
if (tile && !tile.isDead) {
this.drawCandy(this.ctx, tile.visX, tile.visY, this.tileSize, tile.type, tile.special, tile.scale, tile.alpha);
}
}
}
this.ctx.restore();
// Particles (Global Space)
this.ctx.globalCompositeOperation = 'lighter';
this.particles.forEach(p => {
this.ctx.fillStyle = p.color;
this.ctx.globalAlpha = p.life;
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, 4 + p.life*4, 0, Math.PI*2);
this.ctx.fill();
});
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.globalAlpha = 1.0;
this.ctx.restore();
}
gameLoop(time) {
if(!this.isConnected) return;
// Strict DT capping (Pillar 5)
let dt = (time - this.lastTime) / 1000;
if(dt > 0.05) dt = 0.05;
this.lastTime = time;
// Screenshake logic
if(this.shakeTime > 0) this.shakeTime -= dt;
// Petal logic
this.floatingPetals.forEach(p => {
p.y += p.vy * dt; p.a += p.va;
if(p.y > this.canvasHeight + 20) { p.y = -20; p.x = Math.random() * this.canvasWidth; }
});
// Particle logic
for(let i = this.particles.length-1; i>=0; i--) {
let p = this.particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += 800 * dt; // Gravity
p.life -= dt * 1.5;
if(p.life <= 0) this.particles.splice(i, 1);
}
// Tile Tweening Logic (Sync Async Animations)
let isMoving = false;
const speed = 15; // Lerp speed
for (let r = 0; r < this.ROWS; r++) {
for (let c = 0; c < this.COLS; c++) {
let tile = this.grid[r][c];
if (tile && !tile.isDead) {
let targetX = c * this.tileSize;
let targetY = r * this.tileSize;
if (Math.abs(tile.visX - targetX) > 1) {
tile.visX += (targetX - tile.visX) * speed * dt;
isMoving = true;
} else {
tile.visX = targetX;
}
if (Math.abs(tile.visY - targetY) > 1) {
tile.visY += (targetY - tile.visY) * speed * dt;
isMoving = true;
} else {
tile.visY = targetY;
}
}
}
}
// State Transitions when tweens finish
if (!isMoving) {
if (this.currentState === this.STATE.SWAPPING) {
// Swap visually complete, process logically
this.processMatches();
} else if (this.currentState === this.STATE.ANIMATING) {
// Falls complete, process logic
this.processMatches();
}
}
this.draw();
this.rafId = requestAnimationFrame((t) => this.gameLoop(t));
}
}
customElements.define('match-three-game', MatchThreeGame);