Sweet Flora Match

Sweet Flora Match

aop3d tech

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);
Back to blog

Leave a comment