A Pen by semanticentity on CodePen.
Created
July 15, 2025 05:30
-
-
Save semanticentity/73c8e7f78e390d9f7a97b7d201bfa86a to your computer and use it in GitHub Desktop.
GridClash: Match 3 Game
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
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>GridClash: Match3 Game w/ Squircles</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| background-color: #222; | |
| color: white; | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| display: block; | |
| cursor: pointer; | |
| } | |
| #info { | |
| position: absolute; | |
| bottom: 5px; | |
| right: 5px; | |
| background: rgba(0, 0, 0, 0.75); | |
| padding: 5px 10px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); | |
| } | |
| #score { | |
| font-size: 16px; | |
| font-weight: bold; | |
| margin-bottom: 5px; | |
| color: #ffffba; | |
| text-shadow: 0 0 5px #ffffba; | |
| } | |
| /* New: Debugging info */ | |
| #debugInfo { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| background: rgba(0, 0, 0, 0.75); | |
| padding: 5px 10px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| color: #fff; | |
| z-index: 101; | |
| /* Ensure it's above mute button */ | |
| } | |
| /* Mute button styling */ | |
| #muteButton { | |
| position: absolute; | |
| bottom: 10px; | |
| left: 10px; | |
| z-index: 100; | |
| /* Ensure it's above everything */ | |
| background: #3a3a3a; | |
| /* Darker button for dub vibe */ | |
| color: #0f0; | |
| /* Green text for buttons */ | |
| border: 1px solid #555; | |
| padding: 8px 12px; | |
| cursor: pointer; | |
| border-radius: 4px; | |
| transition: background-color 0.2s; | |
| font-weight: bold; | |
| font-family: monospace; | |
| } | |
| #muteButton:hover { | |
| background: #4a4a4a; | |
| border-color: #777; | |
| } | |
| #muteButton:active { | |
| background: #5a5a5a; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info"> | |
| <div id="score">Score: 0</div> | |
| </div> | |
| <!-- Added Debugging Info Div --> | |
| <div id="debugInfo" style="display: none;"> | |
| Pulse Scale: <span id="pulseScaleValue">1.00</span><br> | |
| Energy: <span id="musicalEnergyValue">0.00</span> | |
| </div> | |
| <button id="muteButton" style="display: none;">🔊</button> | |
| <!-- Hidden by default, shown on first click --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| function randomInt(min, max) { | |
| return Math.floor(min + Math.random() * (max - min + 1)); | |
| } | |
| // ======== AUDIO SYSTEM ======== | |
| class AudioSystem { | |
| constructor() { | |
| this.audioCtx = new(window.AudioContext || window.webkitAudioContext)(); | |
| this.isPlaying = false; | |
| this.bpm = randomInt(105, 125); | |
| this.currentStep = 0; | |
| this.sequenceLength = 16; | |
| this.scheduleAheadTime = 0.1; // Schedule 100ms in advance | |
| this.lastBeatTime = 0; // Stores the AudioContext.currentTime of the last strong beat | |
| this.masterGain = this.audioCtx.createGain(); | |
| this.masterGain.gain.value = 1; // Base gain, will be toggled by mute | |
| this.delayNode = this.audioCtx.createDelay(1); | |
| this.delayFeedbackNode = this.audioCtx.createGain(); | |
| this.delayWetGain = this.audioCtx.createGain(); | |
| this.delayDryGain = this.audioCtx.createGain(); | |
| this.reverbNode = this.createReverb(); | |
| this.reverbWetGain = this.audioCtx.createGain(); | |
| this.reverbDryGain = this.audioCtx.createGain(); | |
| this.biquadFilter = this.audioCtx.createBiquadFilter(); | |
| this.biquadFilter.type = "lowpass"; | |
| this.biquadFilter.frequency.value = 2500; // Darker default filter | |
| this.biquadFilter.Q.value = 0.75; | |
| this.compressor = this.audioCtx.createDynamicsCompressor(); | |
| this.compressor.threshold.value = -15; // More aggressive compression for dub "pump" | |
| this.compressor.knee.value = 20; | |
| this.compressor.ratio.value = 10; // Stronger compression | |
| this.compressor.attack.value = 0.015; | |
| this.compressor.release.value = 0.25; // Slower release for thicker sound | |
| this.masterGain.connect(this.biquadFilter); | |
| this.biquadFilter.connect(this.delayDryGain); | |
| this.biquadFilter.connect(this.reverbDryGain); | |
| this.biquadFilter.connect(this.delayNode); | |
| this.delayNode.connect(this.delayFeedbackNode); | |
| this.delayFeedbackNode.connect(this.delayNode); | |
| this.delayNode.connect(this.delayWetGain); | |
| this.biquadFilter.connect(this.reverbNode); | |
| this.reverbNode.connect(this.reverbWetGain); | |
| this.delayDryGain.connect(this.compressor); | |
| this.reverbDryGain.connect(this.compressor); | |
| this.delayWetGain.connect(this.compressor); | |
| this.reverbWetGain.connect(this.compressor); | |
| this.compressor.connect(this.audioCtx.destination); | |
| this.setDelayAmount(0.125); // Delay by default | |
| this.setReverbAmount(0.03); // Reverb by default | |
| this.kickSequence = Array(this.sequenceLength).fill(false); | |
| this.clapSequence = Array(this.sequenceLength).fill(false); | |
| this.closedHatSequence = Array(this.sequenceLength).fill(false); | |
| this.openHatSequence = Array(this.sequenceLength).fill(false); | |
| this.tomSequence = Array(this.sequenceLength).fill(false); | |
| this.bassSequence = Array(this.sequenceLength).fill(false); | |
| this.stabSequence = Array(this.sequenceLength).fill(false); | |
| this.openHatPlayDuration = 3; // Play for 3 bars | |
| this.openHatMuteDuration = 1; // Mute for 1 bars | |
| this.openHatTimbre = 'tick'; // Options: metallic, splash, tick | |
| this.barCounter = 0; | |
| this.scales = { | |
| // Western/Popular | |
| major: [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19, 21, 23, 24], | |
| minor: [0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19, 20, 22, 24], | |
| harmonicMinor: [0, 2, 3, 5, 7, 8, 11, 12, 14, 15, 17, 19, 20, 23, 24], | |
| melodicMinor: [0, 2, 3, 5, 7, 9, 11, 12, 14, 15, 17, 19, 21, 23, 24], | |
| // Pentatonic / Blues | |
| pentatonic: [0, 3, 5, 7, 10, 12, 15, 17, 19, 22, 24], | |
| blues: [0, 3, 5, 6, 7, 10, 12, 15, 17, 18, 19, 22, 24], | |
| // Jazz / Modal | |
| dorian: [0, 2, 3, 5, 7, 9, 10, 12, 14, 15, 17, 19, 21, 22, 24], | |
| phrygian: [0, 1, 3, 5, 7, 8, 10, 12, 13, 15, 17, 19, 20, 22, 24], | |
| lydian: [0, 2, 4, 6, 7, 9, 11, 12, 14, 16, 18, 19, 21, 23, 24], | |
| mixolydian: [0, 2, 4, 5, 7, 9, 10, 12, 14, 16, 17, 19, 21, 22, 24], | |
| locrian: [0, 1, 3, 5, 6, 8, 10, 12, 13, 15, 17, 18, 20, 22, 24], | |
| bebop: [0, 2, 4, 5, 7, 8, 9, 11, 12, 14, 16, 17, 19, 20, 23, 24], | |
| // Global Scales | |
| egyptian: [0, 2, 5, 7, 10, 12, 14, 17, 19, 22, 24], | |
| persian: [0, 1, 4, 5, 7, 8, 11, 12, 13, 16, 17, 19, 20, 23, 24], | |
| arabic: [0, 2, 4, 5, 6, 8, 10, 12, 14, 16, 17, 18, 20, 22, 24], | |
| hirajoshi: [0, 2, 3, 7, 8, 12, 14, 15, 19, 20, 24], | |
| pelog: [0, 1, 3, 7, 8, 12, 13, 15, 19, 20, 24], | |
| wholeTone: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24], | |
| diminished: [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 21, 23, 24], | |
| enigmatic: [0, 1, 4, 6, 8, 10, 11, 12, 13, 16, 18, 20, 22, 23, 24], | |
| // Chromatic and Experimental | |
| chromatic: Array.from({ length: 25 }, (_, i) => i), | |
| fourths: [0, 5, 10, 15, 20, 24], // stacked perfect 4ths | |
| tritones: [0, 6, 12, 18, 24], // symmetrical tritone scale | |
| micro12: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], // one octave chromatic | |
| // Electronic Music | |
| deepHouse: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24], | |
| classicHouse: [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 18, 19, 21, 23, 24], | |
| techHouse: [0, 2, 3, 5, 7, 8, 10, 12, 13, 15, 17, 18, 20, 22, 23, 24], | |
| deepTechHouse: [0, 1, 3, 5, 6, 8, 10, 11, 13, 15, 16, 18, 20, 21, 23, 24], | |
| jazzyDeep: [0, 2, 3, 5, 7, 9, 11, 12, 14, 15, 17, 19, 21, 23, 24], // melodic minor feel | |
| loFiHouse: [0, 2, 5, 7, 9, 10, 12, 14, 17, 19, 21, 22, 24], // sus chords + jazzy 7ths | |
| dubTechno: [0, 1, 5, 6, 7, 10, 12, 13, 17, 18, 19, 22, 24], // minor 9th + dissonance | |
| industrialTechno: [0, 1, 4, 6, 8, 11, 12, 13, 16, 18, 20, 23, 24], // harsh overtones | |
| berlinMinimal: [0, 3, 5, 7, 9, 12, 15, 17, 19, 21, 24], // moody + groove-oriented | |
| acidHouse: [0, 3, 7, 10, 12, 15, 19, 22, 24], // 7th + tritone jumps | |
| detroitTechno: [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24], // jazz-rooted chromatic vibe | |
| afroHouse: [0, 2, 3, 5, 7, 10, 12, 14, 15, 17, 19, 22, 24], // dorian + phrygian blends | |
| tribalHouse: [0, 2, 5, 7, 9, 11, 12, 14, 17, 19, 21, 23, 24], // open 4th/5ths, strong percussive flow | |
| // Aphex Twin Minipops Scales | |
| aphexTwin1: [0, 2, 3, 4, 5, 7, 8, 9, 11, 12, 14, 15, 16, 17, 19, 21, 23, 24], // A minor base + C#, F#, G#, hybrid modal | |
| aphexTwin2: [0, 2, 3, 4, 7, 8, 9, 11, 12, 14, 15, 17, 18, 19, 22, 24], // A melodic minor with chromatic walk tones | |
| aphexTwin3: [0, 1, 3, 4, 5, 7, 10, 11, 12, 13, 16, 17, 19, 20, 23, 24], // Minor Phrygian + harmonic G# and chromatics | |
| aphexTwin4: [0, 2, 3, 5, 6, 8, 9, 11, 12, 13, 15, 17, 18, 20, 22, 24], // Dorian + chromatic passing notes | |
| aphexTwinMicroTonal: [0, 1, 2, 3, 4, 5, 6, 6.5, 7, 8, 8.5, 9, 10, 11, 11.5, 12], // microtonal cluster scale (non-12TET-like) | |
| }; | |
| this.currentScale = "hirajoshi"; | |
| this.baseNote = randomInt(24, 88); | |
| this.bassGroove = 0.9; // More prominent bass by default | |
| this.stabDensity = 0.6; // Stabs will be modulated by energy | |
| this.tribalAmount = 0.369; // Sparse toms for more space | |
| this.filterCutoff = 1000; // Base filter cutoff (will be modulated) | |
| this.hatVariation = 0.75; // Base hat variation (will be modulated) | |
| // Musical Energy System for Game Reaction | |
| this.currentMusicalEnergy = 0.15; // 0 to 1, reflects excitement/intensity | |
| this.energyDecayRate = 0.002; // energy decay per scheduler tick (25ms -> ~0.08 per second) | |
| this.maxEnergyIncreasePerMatch = 0.5; // Max boost per match | |
| this.targetFilterCutoff = this.filterCutoff; | |
| this.targetDelayWet = this.delayWetGain.gain.value; | |
| this.targetReverbWet = this.reverbWetGain.gain.value; | |
| this.targetStabDensity = this.stabDensity; | |
| this.targetHatVariation = this.hatVariation; | |
| this.setScale(this.currentScale); | |
| this.regenerateAllSequences(); | |
| this.scheduler = this.scheduler.bind(this); | |
| // SFX Functions and SFX array | |
| this.sfxFunctions = [ | |
| this.playPop.bind(this), | |
| this.playZap.bind(this), | |
| this.playGunshot.bind(this) | |
| ]; | |
| } | |
| setDelayAmount(amount) { | |
| this.delayWetGain.gain.value = amount; | |
| this.delayDryGain.gain.value = 1 - amount; | |
| this.delayFeedbackNode.gain.value = Math.min(1, amount * 0.09); | |
| const beatDuration = 60 / this.bpm; | |
| this.delayNode.delayTime.value = beatDuration; | |
| } | |
| setReverbAmount(amount) { | |
| this.reverbWetGain.gain.value = amount; | |
| this.reverbDryGain.gain.value = 1 - amount * 0.25; | |
| } | |
| createReverb() { | |
| const convolver = this.audioCtx.createConvolver(); | |
| const impulseLength = this.audioCtx.sampleRate * (3.0 + Math.random() * 2.0); | |
| const impulse = this.audioCtx.createBuffer(2, impulseLength, this.audioCtx.sampleRate); | |
| for (let channel = 0; channel < 2; channel++) { | |
| const impulseData = impulse.getChannelData(channel); | |
| for (let i = 0; i < impulseLength; i++) { | |
| impulseData[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / impulseLength, 3.0 + Math.random() * 1.5); | |
| } | |
| } | |
| convolver.buffer = impulse; | |
| return convolver; | |
| } | |
| // Update musical energy and apply to audio parameters | |
| updateMusicalEnergy() { | |
| this.currentMusicalEnergy = Math.max(0, this.currentMusicalEnergy - this.energyDecayRate); | |
| // Debugging: Log energy | |
| // console.log("Current Musical Energy:", this.currentMusicalEnergy.toFixed(2)); | |
| // Filter: Brighter with more energy (2000 Hz to 9000 Hz) | |
| this.targetFilterCutoff = 2000 + this.currentMusicalEnergy * 7000; | |
| this.biquadFilter.frequency.setTargetAtTime(this.targetFilterCutoff, this.audioCtx.currentTime, 0.05); | |
| // Delay: More pronounced with more energy (0.4 to 0.8) | |
| this.targetDelayWet = 0.4 + this.currentMusicalEnergy * 0.4; | |
| this.delayWetGain.gain.setTargetAtTime(this.targetDelayWet, this.audioCtx.currentTime, 0.05); | |
| this.delayFeedbackNode.gain.setTargetAtTime(Math.min(0.95, this.targetDelayWet * 0.9), this.audioCtx.currentTime, 0.05); | |
| // Reverb: More space with more energy (0.3 to 0.7) | |
| this.targetReverbWet = 0.3 + this.currentMusicalEnergy * 0.4; | |
| this.reverbWetGain.gain.setTargetAtTime(this.targetReverbWet, this.audioCtx.currentTime, 0.05); | |
| // Stab Density: More stabs with more energy | |
| this.stabDensity = 0.25 + this.currentMusicalEnergy * 0.5; | |
| // Hat Variation: Busier hats with more energy (0.3 to 0.7) | |
| this.hatVariation = 0.3 + this.currentMusicalEnergy * 0.4; | |
| } | |
| // Method to be called by game when a match happens | |
| addMatchEnergy(matchLength) { | |
| // Boost energy based on match length (e.g., 3-match gives ~0.1, 5-match gives ~0.25) | |
| const boost = Math.min(this.maxEnergyIncreasePerMatch, (matchLength - 2) * 0.05); | |
| this.currentMusicalEnergy = Math.min(1, this.currentMusicalEnergy + boost); | |
| // console.log("Match! Energy boosted to:", this.currentMusicalEnergy.toFixed(2)); | |
| // Instant, subtle filter pop for feedback | |
| const tempFilterPop = 1000 + matchLength * 200; // immediate jump based on match size | |
| this.biquadFilter.frequency.setValueAtTime(this.biquadFilter.frequency.value + tempFilterPop, this.audioCtx.currentTime); | |
| this.biquadFilter.frequency.exponentialRampToValueAtTime(this.targetFilterCutoff, this.audioCtx.currentTime + 0.15); // Quick decay | |
| } | |
| _regenerateKickSequence() { | |
| this.kickSequence.fill(false); | |
| if (Math.random() < 0.4) { | |
| for (let i = 0; i < this.sequenceLength; i += 4) { | |
| this.kickSequence[i] = true; | |
| } | |
| } else if (Math.random() < 0.8) { | |
| this.kickSequence[8] = true; | |
| if (Math.random() < 0.5) this.kickSequence[0] = true; | |
| if (Math.random() < 0.2) this.kickSequence[4] = true; | |
| } else { | |
| this.kickSequence[0] = true; | |
| if (Math.random() < 0.6) this.kickSequence[6] = true; | |
| if (Math.random() < 0.4) this.kickSequence[10] = true; | |
| if (Math.random() < 0.7) this.kickSequence[14] = true; | |
| } | |
| } | |
| _regenerateClapSequence() { | |
| this.clapSequence.fill(false); | |
| if (this.kickSequence[8] && !this.kickSequence[0] && !this.kickSequence[4]) { | |
| this.clapSequence[8] = true; | |
| } else { | |
| this.clapSequence[4] = true; | |
| this.clapSequence[12] = true; | |
| } | |
| if (Math.random() < 0.2) this.clapSequence[4] = false; | |
| } | |
| _regenerateClosedHatSequence() { | |
| this.closedHatSequence.fill(false); | |
| const p = this.hatVariation; | |
| if (p < 0.3) { | |
| for (let i = 0; i < this.sequenceLength; i++) { | |
| if (i % 4 === 0) this.closedHatSequence[i] = true; | |
| else if (i % 2 === 0 && Math.random() < 0.2) this.closedHatSequence[i] = true; | |
| } | |
| } else if (p < 0.6) { | |
| for (let i = 0; i < this.sequenceLength; i++) { | |
| if (i % 2 === 0) this.closedHatSequence[i] = true; | |
| else if (Math.random() < 0.3) this.closedHatSequence[i] = true; | |
| } | |
| } else { | |
| const steady8ths = Math.random() < 0.5; | |
| for (let i = 0; i < this.sequenceLength; i++) { | |
| if (steady8ths && i % 2 === 0) this.closedHatSequence[i] = true; | |
| else if (!steady8ths && Math.random() < 0.5) this.closedHatSequence[i] = true; | |
| } | |
| } | |
| for (let i = 0; i < this.sequenceLength; i++) { | |
| if (this.kickSequence[i] || this.clapSequence[i] || (this.openHatSequence[i] && this.openHatPlayDuration > 0)) { | |
| this.closedHatSequence[i] = false; | |
| } | |
| } | |
| } | |
| _regenerateOpenHatPattern() { | |
| this.openHatSequence.fill(false); | |
| for (let i = 2; i < this.sequenceLength; i += 4) { | |
| if (Math.random() > 0.2) this.openHatSequence[i] = true; | |
| } | |
| } | |
| _regenerateTomSequence() { | |
| this.tomSequence = Array(this.sequenceLength).fill(false).map(() => Math.random() < this.tribalAmount * 0.1); | |
| } | |
| _regenerateBassSequence() { | |
| this.bassSequence.fill(false); | |
| let lastBassStep = -4; | |
| for (let i = 0; i < this.sequenceLength; i++) { | |
| const prob = (this.kickSequence[i] ? 0.8 : 0.2) * this.bassGroove; | |
| if (Math.random() < prob) { | |
| if (i - lastBassStep >= (Math.random() < 0.5 ? 2 : 4)) { | |
| this.bassSequence[i] = true; | |
| lastBassStep = i; | |
| } | |
| } | |
| } | |
| if (!this.bassSequence.some(s => s)) { | |
| this.bassSequence[0] = true; | |
| if (Math.random() < 0.5) this.bassSequence[8] = true; | |
| } | |
| } | |
| _regenerateStabSequence() { | |
| this.stabSequence.fill(false); | |
| for (let i = 0; i < this.sequenceLength; i++) { | |
| if (i % 4 === 2 || i % 4 === 3 || i % 4 === 6 || i % 4 === 7 || | |
| i % 4 === 10 || i % 4 === 11 || i % 4 === 14 || i % 4 === 15) { | |
| if (Math.random() < this.stabDensity * 0.7) this.stabSequence[i] = true; | |
| } else if (Math.random() < this.stabDensity * 0.05) { | |
| this.stabSequence[i] = true; | |
| } | |
| } | |
| } | |
| regenerateAllSequences() { | |
| this._regenerateKickSequence(); | |
| this._regenerateClapSequence(); | |
| this._regenerateClosedHatSequence(); | |
| this._regenerateOpenHatPattern(); | |
| this._regenerateTomSequence(); | |
| this._regenerateBassSequence(); | |
| this._regenerateStabSequence(); | |
| } | |
| setScale(scale) { | |
| this.currentScale = scale; | |
| switch (scale) { | |
| case 'minor': | |
| case 'blues': | |
| this.openHatTimbre = 'metallic'; | |
| break; | |
| case 'pentatonic': | |
| this.openHatTimbre = 'tick'; | |
| break; | |
| case 'major': | |
| case 'chromatic': | |
| this.openHatTimbre = 'splash'; | |
| break; | |
| default: | |
| this.openHatTimbre = 'metallic'; | |
| } | |
| this._regenerateBassSequence(); | |
| this._regenerateStabSequence(); | |
| // Hat control mode is now handled by energy, but keep this for scale dependency | |
| const scaleHatMap = { | |
| minor: 0.4, | |
| pentatonic: 0.3, | |
| blues: 0.6, | |
| major: 0.2, | |
| chromatic: 0.5 | |
| }; | |
| this.hatVariation = scaleHatMap[this.currentScale] || 0.4; | |
| this._regenerateClosedHatSequence(); | |
| } | |
| playKick(time) { | |
| const osc = this.audioCtx.createOscillator(); | |
| const gainNode = this.audioCtx.createGain(); | |
| osc.type = 'sine'; | |
| osc.frequency.setValueAtTime(80, time); | |
| osc.frequency.exponentialRampToValueAtTime(30, time + 0.15); | |
| gainNode.gain.setValueAtTime(1.5, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, time + 0.22); | |
| osc.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| osc.start(time); | |
| osc.stop(time + 0.25); | |
| // --- FAKE SIDECHAIN --- | |
| this.reverbWetGain.gain.setTargetAtTime(0.1, time, 0.001); // Duck | |
| this.reverbWetGain.gain.setTargetAtTime(this.targetReverbWet, time + 0.1, 0.01); // Restore | |
| } | |
| playClap(time) { | |
| const gainNode = this.audioCtx.createGain(); | |
| gainNode.gain.setValueAtTime(0.6, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, time + 0.1); | |
| gainNode.connect(this.masterGain); | |
| const noise = this.audioCtx.createBufferSource(); | |
| const bufferSize = this.audioCtx.sampleRate * 0.08; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let j = 0; j < bufferSize; j++) { | |
| data[j] = Math.random() * 2 - 1; | |
| } | |
| noise.buffer = buffer; | |
| const bandpass = this.audioCtx.createBiquadFilter(); | |
| bandpass.type = 'bandpass'; | |
| bandpass.frequency.value = 1600 + Math.random() * 500; | |
| bandpass.Q.value = 2 + Math.random() * 1.5; | |
| noise.connect(bandpass); | |
| bandpass.connect(gainNode); | |
| noise.start(time); | |
| noise.stop(time + 0.08); | |
| if (Math.random() < 0.5) { | |
| const oscTone = this.audioCtx.createOscillator(); | |
| oscTone.type = 'triangle'; | |
| oscTone.frequency.setValueAtTime(150 + Math.random() * 40, time); | |
| oscTone.frequency.exponentialRampToValueAtTime(80, time + 0.08); | |
| const toneGain = this.audioCtx.createGain(); | |
| toneGain.gain.setValueAtTime(0.3, time); | |
| toneGain.gain.exponentialRampToValueAtTime(0.01, time + 0.1); | |
| oscTone.connect(toneGain); | |
| toneGain.connect(gainNode); | |
| oscTone.start(time); | |
| oscTone.stop(time + 0.1); | |
| } | |
| } | |
| playClosedHat(time) { | |
| const gainNode = this.audioCtx.createGain(); | |
| const hipass = this.audioCtx.createBiquadFilter(); | |
| hipass.type = "highpass"; | |
| hipass.frequency.value = 5000 + Math.random() * 1500; | |
| hipass.Q.value = 2 + Math.random() * 2; | |
| const noise = this.audioCtx.createBufferSource(); | |
| const bufferSize = this.audioCtx.sampleRate * 0.025; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; | |
| noise.buffer = buffer; | |
| noise.connect(hipass); | |
| hipass.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| gainNode.gain.setValueAtTime(0.25, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.025); | |
| noise.start(time); | |
| noise.stop(time + 0.5); | |
| } | |
| playOpenHat(time) { | |
| const gainNode = this.audioCtx.createGain(); | |
| gainNode.connect(this.masterGain); | |
| let duration = 0.025 + Math.random() * 0.05; | |
| switch (this.openHatTimbre) { | |
| case 'metallic': | |
| const bandpass = this.audioCtx.createBiquadFilter(); | |
| bandpass.type = "bandpass"; | |
| bandpass.frequency.value = 1000 + Math.random() * 1000; | |
| bandpass.Q.value = 1.5 + Math.random() * 2; | |
| const baseFreq = 200 + Math.random() * 100; | |
| for (let i = 0; i < 5; i++) { | |
| const osc = this.audioCtx.createOscillator(); | |
| osc.type = 'square'; | |
| osc.frequency.value = baseFreq * (i * 1.5 + 1) * (1 + (Math.random() - 0.5) * 0.05); | |
| osc.connect(bandpass); | |
| osc.start(time); | |
| osc.stop(time + duration); | |
| } | |
| bandpass.connect(gainNode); | |
| gainNode.gain.setValueAtTime(0.075, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, time + duration); | |
| break; | |
| case 'splash': | |
| duration = 0.3; | |
| const noiseSplash = this.audioCtx.createBufferSource(); | |
| const bufferSizeSplash = this.audioCtx.sampleRate * duration; | |
| const bufferSplash = this.audioCtx.createBuffer(1, bufferSizeSplash, this.audioCtx.sampleRate); | |
| const dataSplash = bufferSplash.getChannelData(0); | |
| for (let i = 0; i < bufferSizeSplash; i++) dataSplash[i] = Math.random() * 2 - 1; | |
| noiseSplash.buffer = bufferSplash; | |
| const filterSplash = this.audioCtx.createBiquadFilter(); | |
| filterSplash.type = "bandpass"; | |
| filterSplash.frequency.value = 4000 + Math.random() * 1500; | |
| filterSplash.Q.value = 2.5 + Math.random() * 3; | |
| noiseSplash.connect(filterSplash); | |
| filterSplash.connect(gainNode); | |
| gainNode.gain.setValueAtTime(0.25, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + duration); | |
| noiseSplash.start(time); | |
| noiseSplash.stop(time + duration); | |
| break; | |
| case 'tick': | |
| duration = 0.125; | |
| const oscTick = this.audioCtx.createOscillator(); | |
| oscTick.type = 'triangle'; | |
| const startFreqTick = 250 + Math.random() * 200; | |
| const minFreq = 10; | |
| const endFreqTick = Math.max(minFreq, startFreqTick / 4); | |
| oscTick.frequency.setValueAtTime(startFreqTick, time); | |
| oscTick.frequency.exponentialRampToValueAtTime(endFreqTick, time + duration * 0.8); | |
| oscTick.connect(gainNode); | |
| gainNode.gain.setValueAtTime(0.25, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + duration); | |
| oscTick.start(time); | |
| oscTick.stop(time + duration); | |
| break; | |
| default: | |
| this.openHatTimbre = 'metallic'; | |
| this.playOpenHat(time); | |
| return; | |
| } | |
| } | |
| playTom(time) { | |
| const osc = this.audioCtx.createOscillator(); | |
| const gainNode = this.audioCtx.createGain(); | |
| osc.type = 'sine'; | |
| const baseFreq = 50 + Math.random() * 25; | |
| osc.frequency.setValueAtTime(baseFreq * 1.8, time); | |
| osc.frequency.exponentialRampToValueAtTime(baseFreq * 0.8, time + 0.2); | |
| gainNode.gain.setValueAtTime(0.9, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, time + 0.25); | |
| osc.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| osc.start(time); | |
| osc.stop(time + 0.25); | |
| } | |
| playBass(time, step) { | |
| const osc = this.audioCtx.createOscillator(); | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| const gainNode = this.audioCtx.createGain(); | |
| osc.type = "sine"; | |
| const scaleNotes = this.scales[this.currentScale]; | |
| const noteIndex = Math.floor(Math.random() * (scaleNotes.length / 3)); | |
| const octaveDrop = Math.random() < 0.8 ? 12 : (Math.random() < 0.4 ? 24 : 0); | |
| const note = this.baseNote + scaleNotes[noteIndex] - octaveDrop; | |
| const freq = 440 * Math.pow(2, (note - 69) / 12); | |
| osc.frequency.value = freq; | |
| filter.type = "lowpass"; | |
| filter.frequency.setValueAtTime(freq * (Math.random() * 2 + 1.2), time); | |
| filter.frequency.exponentialRampToValueAtTime(freq * (Math.random() * 0.8 + 0.8), time + 0.15); | |
| filter.Q.value = 0.3 + Math.random() * 0.4; | |
| osc.connect(filter); | |
| filter.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| const duration = (60 / this.bpm) * (Math.random() < 0.6 ? 1.5 : 2.0); | |
| gainNode.gain.setValueAtTime(0.8 * this.bassGroove, time); | |
| gainNode.gain.setValueAtTime(gainNode.gain.value, time + duration * 1.1); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, time + duration); | |
| osc.start(time); | |
| osc.stop(time + duration); | |
| } | |
| playStab(time, step) { | |
| // Add a random chance for a note to be a 303-style slide note. | |
| const isSlideNote = Math.random() < 0.15; // 15% chance for a slide | |
| const sixteenthNoteDuration = 15.0 / this.bpm; // Duration of one 16th note in seconds | |
| // If it's a slide note, make it longer. Otherwise, use the original duration logic. | |
| // "0.369" is about two 16th notes. We'll add that duration for the sustain. | |
| let duration; | |
| if (isSlideNote) { | |
| // Make the note longer to allow for the slide and sustain. Lasts for four 16th notes. | |
| duration = sixteenthNoteDuration * 4 + (0.369 + Math.random() * 0.369); | |
| } else { | |
| // Original duration logic for normal stabs | |
| var durationRange = 0.963 - 0.369; | |
| duration = (60 / this.bpm) * (0.369 + Math.random() * durationRange); | |
| } | |
| const numVoices = Math.random() < 0.8 ? 1 : Math.floor(Math.random() * 9) + 2; | |
| const scaleNotes = this.scales[this.currentScale]; | |
| const baseStabNote = this.baseNote + 12 + scaleNotes[Math.floor(Math.random() * (scaleNotes.length * 0.6)) + Math.floor(scaleNotes.length * 0.3)]; | |
| const stabTypes = ['sawtooth', 'square']; | |
| const stabType = stabTypes[Math.floor(Math.random() * stabTypes.length)]; | |
| for (let i = 0; i < numVoices; i++) { | |
| const osc = this.audioCtx.createOscillator(); | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| const gainNode = this.audioCtx.createGain(); | |
| // Give slide notes a higher, more squelchy filter resonance (Q value). | |
| filter.Q.value = isSlideNote ? 5.0 + Math.random() * 4.0 : 1.0 + Math.random() * 1.0; | |
| osc.type = stabType; | |
| let note = baseStabNote; | |
| if (i !== 0) { | |
| const interval = scaleNotes[Math.floor(Math.random() * scaleNotes.length)]; | |
| note += interval; | |
| } | |
| note = Math.min(this.baseNote + 48, Math.max(this.baseNote + 12, note)); | |
| const freq = 440 * Math.pow(2, (note - 69) / 12); | |
| // --- PITCH SLIDE LOGIC --- | |
| if (isSlideNote) { | |
| // This is the note we are sliding TO. | |
| const endFreq = freq; | |
| const slideDuration = sixteenthNoteDuration * (1.5 + Math.random() * 2); // Randomize speed | |
| let startFreq; | |
| // NEW: 50% chance to slide down, 50% chance to slide up | |
| if (Math.random() < 0.5) { | |
| // SLIDE DOWN: Pick a higher note from the scale to start from | |
| const slideFromNote = note + (scaleNotes[Math.floor(Math.random() * 5) + 2] || 7); | |
| startFreq = 440 * Math.pow(2, (slideFromNote - 69) / 12); | |
| } else { | |
| // SLIDE UP: Pick a lower note to start from | |
| const slideFromNote = note - (scaleNotes[Math.floor(Math.random() * 5)] || 7); | |
| startFreq = 440 * Math.pow(2, (slideFromNote - 69) / 12); | |
| } | |
| osc.frequency.setValueAtTime(startFreq, time); | |
| osc.frequency.linearRampToValueAtTime(endFreq, time + slideDuration); | |
| } else { | |
| // Behavior for non-slide notes | |
| osc.frequency.value = freq * (1 + (Math.random() - 0.5) * 0.005 * i); // Randomize pitch | |
| } | |
| // --- END OF PITCH SLIDE LOGIC --- | |
| filter.type = "lowpass"; | |
| const filterStartFreq = freq * (2.5 + Math.random() * 3); | |
| filter.frequency.setValueAtTime(filterStartFreq, time); | |
| // Adjust the filter envelope to keep it open longer for the slide. | |
| const filterRampEndTime = time + duration * (isSlideNote ? 0.95 : 0.9); | |
| filter.frequency.exponentialRampToValueAtTime(Math.max(300, freq / 1.5), filterRampEndTime); | |
| osc.connect(filter); | |
| filter.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| gainNode.gain.setValueAtTime(0, time); | |
| gainNode.gain.linearRampToValueAtTime(0.125 / numVoices, time + 0.005); | |
| gainNode.gain.setValueAtTime(0.125 / numVoices, time + duration * 0.4); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + duration); | |
| osc.start(time); | |
| osc.stop(time + duration); | |
| } | |
| } | |
| playGunshot(time) { | |
| const burstGain = this.audioCtx.createGain(); | |
| burstGain.gain.setValueAtTime(1.8, time); | |
| burstGain.gain.exponentialRampToValueAtTime(0.01, time + 0.2); | |
| const noise = this.audioCtx.createBufferSource(); | |
| const bufferSize = this.audioCtx.sampleRate * 0.15; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { | |
| data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 0.5); | |
| } | |
| noise.buffer = buffer; | |
| const bandpass = this.audioCtx.createBiquadFilter(); | |
| bandpass.type = 'bandpass'; | |
| bandpass.frequency.value = 700 + Math.random() * 500; | |
| bandpass.Q.value = 0.8 + Math.random() * 0.5; | |
| noise.connect(bandpass); | |
| bandpass.connect(burstGain); | |
| burstGain.connect(this.masterGain); | |
| noise.start(time); | |
| noise.stop(time + 0.5); | |
| } | |
| playRewind(time) { | |
| const duration = 0.35 + Math.random() * 0.25; | |
| const gainNode = this.audioCtx.createGain(); | |
| gainNode.gain.setValueAtTime(0.0, time); | |
| gainNode.gain.linearRampToValueAtTime(0.5, time + duration * 0.1); | |
| gainNode.gain.setValueAtTime(0.5, time + duration * 0.8); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, time + duration); | |
| const noise = this.audioCtx.createBufferSource(); | |
| const bufferSize = this.audioCtx.sampleRate * duration; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { | |
| data[i] = Math.random() * 2 - 1; | |
| } | |
| noise.buffer = buffer; | |
| const bandpass = this.audioCtx.createBiquadFilter(); | |
| bandpass.type = 'bandpass'; | |
| bandpass.Q.value = 4 + Math.random() * 4; | |
| bandpass.frequency.setValueAtTime(150 + Math.random() * 100, time); | |
| bandpass.frequency.exponentialRampToValueAtTime(7000 + Math.random() * 3000, time + duration * 0.95); | |
| noise.connect(bandpass); | |
| bandpass.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| noise.start(time); | |
| noise.stop(time + duration); | |
| } | |
| // SFX: PlayPop, PlayZap, PlayCollect, PlayInvalid, PlayWhoosh | |
| playPop(time) { | |
| const osc = this.audioCtx.createOscillator(); | |
| const gainNode = this.audioCtx.createGain(); | |
| osc.type = 'triangle'; | |
| const startFreq = 2000 + Math.random() * 1000; | |
| osc.frequency.setValueAtTime(startFreq, time); | |
| osc.frequency.exponentialRampToValueAtTime(startFreq * 0.2, time + 0.08); // Quick fall | |
| gainNode.gain.setValueAtTime(0.3, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.1); | |
| osc.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| osc.start(time); | |
| osc.stop(time + 0.1); | |
| } | |
| playZap(time) { | |
| const osc = this.audioCtx.createOscillator(); | |
| const gainNode = this.audioCtx.createGain(); | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| osc.type = 'sawtooth'; | |
| const startFreq = 100 + Math.random() * 50; | |
| osc.frequency.setValueAtTime(startFreq, time); | |
| osc.frequency.linearRampToValueAtTime(800 + Math.random() * 400, time + 0.1); | |
| filter.type = 'highpass'; | |
| filter.frequency.setValueAtTime(startFreq * 0.8, time); | |
| filter.frequency.linearRampToValueAtTime(osc.frequency.value * 1.5, time + 0.1); | |
| filter.Q.value = 5; | |
| gainNode.gain.setValueAtTime(0.4, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.15); | |
| osc.connect(filter); | |
| filter.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| osc.start(time); | |
| osc.stop(time + 0.15); | |
| } | |
| playCollect(time) { // For when a match completes | |
| const osc = this.audioCtx.createOscillator(); | |
| const gainNode = this.audioCtx.createGain(); | |
| osc.type = 'sine'; | |
| const startFreq = 400 + Math.random() * 100; | |
| osc.frequency.setValueAtTime(startFreq, time); | |
| osc.frequency.linearRampToValueAtTime(startFreq * 1.2, time + 0.05); | |
| osc.frequency.exponentialRampToValueAtTime(startFreq * 1.5, time + 0.2); | |
| gainNode.gain.setValueAtTime(0.5, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.2); | |
| osc.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| osc.start(time); | |
| osc.stop(time + 0.3); | |
| } | |
| playInvalid(time) { // For an invalid move or deselection | |
| const osc = this.audioCtx.createOscillator(); | |
| const gainNode = this.audioCtx.createGain(); | |
| osc.type = 'square'; | |
| const startFreq = 33 + Math.random() * 60; | |
| osc.frequency.setValueAtTime(startFreq, time); | |
| osc.frequency.exponentialRampToValueAtTime(startFreq * 0.5, time + 0.15); // Fall in pitch | |
| gainNode.gain.setValueAtTime(0.3, time); | |
| gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.2); | |
| osc.connect(gainNode); | |
| gainNode.connect(this.masterGain); | |
| osc.start(time); | |
| osc.stop(time + 0.125); | |
| } | |
| playWhoosh(time) { | |
| const noise = this.audioCtx.createBufferSource(); | |
| const bufferSize = this.audioCtx.sampleRate * 0.3; | |
| const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 0.5); | |
| noise.buffer = buffer; | |
| const filter = this.audioCtx.createBiquadFilter(); | |
| filter.type = "lowpass"; | |
| filter.frequency.setValueAtTime(500, time); | |
| filter.frequency.linearRampToValueAtTime(5000, time + 0.15); // Sweep up | |
| filter.Q.value = 1; | |
| const gain = this.audioCtx.createGain(); | |
| gain.gain.setValueAtTime(0.0, time); | |
| gain.gain.linearRampToValueAtTime(0.2, time + 0.05); | |
| gain.gain.exponentialRampToValueAtTime(0.001, time + 0.3); | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(this.masterGain); | |
| noise.start(time); | |
| noise.stop(time + 0.3); | |
| } | |
| // END SFX | |
| nextStep() { | |
| const secondsPerBeat = 15.0 / this.bpm; // 16th note duration | |
| this.currentStep = (this.currentStep + 1) % this.sequenceLength; | |
| this.nextNoteTime += secondsPerBeat; | |
| if (this.currentStep === 0) { | |
| this.barCounter++; | |
| // Periodically regenerate parts, but keep it subtle for game music | |
| if (this.barCounter % 4 === 0 && Math.random() < 0.2) { | |
| if (Math.random() < 0.5) this._regenerateBassSequence(); | |
| if (Math.random() < 0.5) this._regenerateStabSequence(); | |
| if (Math.random() < 0.3) this._regenerateClosedHatSequence(); | |
| } | |
| } | |
| } | |
| scheduler() { | |
| // Only schedule if audio context is running | |
| if (this.audioCtx.state === 'running') { | |
| this.updateMusicalEnergy(); // Update energy and apply to audio params | |
| while (this.nextNoteTime < this.audioCtx.currentTime + this.scheduleAheadTime) { | |
| const step = this.currentStep; | |
| const playTime = this.nextNoteTime; | |
| this.playSoundsForStep(step, playTime); | |
| this.nextStep(); | |
| } | |
| } else { | |
| console.warn("AudioContext is not running. Suspending scheduler."); | |
| } | |
| this.timerID = setTimeout(this.scheduler, 25); | |
| } | |
| playSoundsForStep(step, playTime) { | |
| let isStrongBeat = false; // Flag to indicate a strong beat for visual sync | |
| if (this.kickSequence[step]) { | |
| this.playKick(playTime); | |
| isStrongBeat = true; // Kick is a strong beat | |
| } | |
| if (this.clapSequence[step]) this.playClap(playTime); | |
| if (this.closedHatSequence[step]) this.playClosedHat(playTime); | |
| let hatsMuted = false; | |
| const playBars = this.openHatPlayDuration; | |
| const muteBars = this.openHatMuteDuration; | |
| if (playBars <= 0) { | |
| hatsMuted = true; | |
| } else if (muteBars <= 0) { | |
| hatsMuted = false; | |
| } else { | |
| const cycleLen = playBars + muteBars; | |
| const barInCycle = this.barCounter % cycleLen; | |
| hatsMuted = barInCycle >= playBars; | |
| } | |
| if (this.openHatSequence[step] && !hatsMuted) { | |
| this.playOpenHat(playTime); | |
| } | |
| if (this.tomSequence[step]) this.playTom(playTime); | |
| if (this.bassSequence[step]) { | |
| this.playBass(playTime, step); | |
| isStrongBeat = true; // Bass is also a strong beat | |
| } | |
| if (this.stabSequence[step]) this.playStab(playTime, step); | |
| // Update lastBeatTime if a kick or bass was played | |
| if (isStrongBeat) { | |
| this.lastBeatTime = playTime; | |
| // console.log("Beat detected at:", playTime.toFixed(3), " (Audio Time)"); // Debug: log beat detection | |
| } | |
| } | |
| async start() { | |
| if (this.isPlaying) return; // Prevent multiple starts | |
| // Always attempt to resume/start the context on user interaction | |
| if (this.audioCtx.state === 'suspended' || this.audioCtx.state === 'interrupted') { | |
| try { | |
| await this.audioCtx.resume(); | |
| // console.log("AudioContext resumed successfully."); | |
| } catch (e) { | |
| console.error("Failed to resume AudioContext:", e); | |
| // If resume fails, don't proceed to start the scheduler | |
| return; | |
| } | |
| } | |
| // Only start the scheduler if the context is actually running | |
| if (this.audioCtx.state === 'running') { | |
| this.isPlaying = true; | |
| this.currentStep = this.sequenceLength - 1; // Start from -1 so first nextStep advances to 0 | |
| this.nextNoteTime = this.audioCtx.currentTime + 0.05; // Schedule first note slightly in future | |
| this.barCounter = 0; | |
| this.scheduler(); // Start the scheduler loop | |
| // console.log("Audio system started and scheduler initiated."); | |
| } else { | |
| console.warn("AudioContext is not running after resume attempt. State:", this.audioCtx.state); | |
| // This could happen if permission was denied, or some other browser issue. | |
| } | |
| } | |
| stop() { | |
| if (!this.isPlaying) return; | |
| this.isPlaying = false; | |
| clearTimeout(this.timerID); | |
| // Also suspend the audio context to release resources | |
| if (this.audioCtx.state === 'running') { | |
| this.audioCtx.suspend().then(() => { | |
| console.log("AudioContext suspended."); | |
| }); | |
| } | |
| } | |
| setBPM(value) { | |
| this.bpm = parseFloat(value); | |
| this.setDelayAmount(this.delayWetGain.gain.value); | |
| } | |
| } // END AUDIO SYSTEM | |
| // GAME LOGIC STARTS HERE | |
| let scene, camera, renderer; | |
| let raycaster, mouse; | |
| const clock = new THREE.Clock(); // For delta time | |
| const GRID_SIZE = 6; | |
| const SQUIRCLE_SIZE = 1.8; | |
| const SQUIRCLE_SPACING = 0.2; | |
| const CELL_SIZE = SQUIRCLE_SIZE + SQUIRCLE_SPACING; | |
| const clayPalette = ['#4e0a1e', '#85122f', '#b81f47', '#e53965', '#f37792', '#f9aec1', '#fde0e8'] | |
| let squircleGeometry; | |
| const squircleMaterials = []; | |
| let grid = []; | |
| let selectedSquircle = null; | |
| let isSwapping = false; | |
| let isCascading = false; | |
| let score = 0; | |
| let lastScaleCheckpoint = 0; | |
| const SCORE_PENALTY_NO_MATCH = 5; | |
| const extrudeSettings = { | |
| depth: 0.9, | |
| bevelEnabled: true, | |
| steps: 4, | |
| bevelSegments: 12, // more segments | |
| bevelSize: 0.090210, // smaller size | |
| bevelThickness: 0.05 // thinner bevel | |
| }; | |
| // const extrudeSettings = { | |
| // depth: 0.9, | |
| // bevelEnabled: true, | |
| // bevelSegments: 9, | |
| // steps: 4, | |
| // bevelSize: 0.1, | |
| // bevelThickness: 0.125 | |
| // }; | |
| const CAMERA_VIEW_FACTOR = 1.1; | |
| // Variables for squircle pulse effect | |
| let currentPulseScale = 1.0; // The current scale applied to squircles for the pulse effect | |
| let lastVisualBeatTime = 0; // The AudioContext.currentTime when the last visual pulse was triggered | |
| // Variables for occasional random scale | |
| let nextRandomScaleTriggerTime = 0; | |
| const RANDOM_SCALE_TRIGGER_INTERVAL_MIN = 2; // seconds | |
| const RANDOM_SCALE_TRIGGER_INTERVAL_MAX = 5; // seconds | |
| const RANDOM_SCALE_ANIM_DURATION_MIN = 300; // milliseconds | |
| const RANDOM_SCALE_ANIM_DURATION_MAX = 600; // milliseconds | |
| const RANDOM_SCALE_FACTOR_MIN = 1.05; // Max additional scale for random effect | |
| const RANDOM_SCALE_FACTOR_MAX = 1.25; // Max additional scale for random effect | |
| const activeParticleSystems = []; | |
| const PARTICLE_GRAVITY = 3.5; | |
| const PARTICLE_COUNT_PER_BURST = 500; | |
| const PARTICLE_SPREAD = 3.5; | |
| const PARTICLE_SIZE = 5; | |
| const PARTICLE_LIFESPAN_MIN = 0.4; | |
| const PARTICLE_LIFESPAN_MAX = 0.8; | |
| // Audio System instance for the game | |
| let audioSystem; | |
| let hasMusicStarted = false; // Flag to track if music has started | |
| let isMuted = false; // Flag for mute state | |
| function generateSquirclePoints(r = 1, steps = 32) { | |
| const points = []; | |
| for (let i = 0; i <= steps; i++) { | |
| const theta = (i / steps) * 2 * Math.PI; | |
| const exponent = 0.5; | |
| const x = r * Math.sign(Math.cos(theta)) * Math.pow(Math.abs(Math.cos(theta)), exponent); | |
| const y = r * Math.sign(Math.sin(theta)) * Math.pow(Math.abs(Math.sin(theta)), exponent); | |
| points.push(new THREE.Vector2(x, y)); | |
| } | |
| return points; | |
| } | |
| function init() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x121210); | |
| renderer = new THREE.WebGLRenderer({ | |
| antialias: true | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| renderer.physicallyCorrectLights = true; | |
| document.body.appendChild(renderer.domElement); | |
| camera = new THREE.OrthographicCamera(); | |
| camera.position.set(0, 0, 10); | |
| camera.lookAt(scene.position); | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1.3); | |
| directionalLight.position.set(8, 12, 15); | |
| scene.add(directionalLight); | |
| raycaster = new THREE.Raycaster(); | |
| mouse = new THREE.Vector2(); | |
| const points = generateSquirclePoints(1, 64); | |
| const shape = new THREE.Shape(points); | |
| squircleGeometry = new THREE.ExtrudeGeometry(shape, extrudeSettings); | |
| squircleGeometry.computeVertexNormals(); | |
| squircleGeometry.center(); | |
| squircleGeometry.scale(SQUIRCLE_SIZE / 2, SQUIRCLE_SIZE / 2, 1); | |
| clayPalette.forEach(colorHex => { | |
| squircleMaterials.push(new THREE.MeshStandardMaterial({ | |
| color: new THREE.Color(colorHex), | |
| roughness: 0.1, | |
| metalness: 0.05, | |
| flatShading: false | |
| })); | |
| }); | |
| initGrid(); | |
| updateScoreDisplay(); | |
| renderer.render(scene, camera); | |
| // Initialize Audio System | |
| audioSystem = new AudioSystem(); | |
| onWindowResize(); | |
| window.addEventListener('resize', onWindowResize, false); | |
| window.addEventListener('click', onClick, false); | |
| // Initialize the first random scale trigger time | |
| nextRandomScaleTriggerTime = clock.elapsedTime + (Math.random() * (RANDOM_SCALE_TRIGGER_INTERVAL_MAX - RANDOM_SCALE_TRIGGER_INTERVAL_MIN) + RANDOM_SCALE_TRIGGER_INTERVAL_MIN); | |
| animate(); | |
| } | |
| function createSquircle(row, col) { | |
| const colorIndex = Math.floor(Math.random() * clayPalette.length); | |
| const geometry = squircleGeometry; | |
| const material = squircleMaterials[colorIndex]; | |
| const squircleMesh = new THREE.Mesh(geometry, material); | |
| const x = (col - GRID_SIZE / 2 + 0.5) * CELL_SIZE; | |
| const y = (row - GRID_SIZE / 2 + 0.5) * CELL_SIZE; | |
| squircleMesh.position.set(x, y, 0); | |
| squircleMesh.userData = { | |
| row, | |
| col, | |
| colorIndex, | |
| originalY: y, | |
| isMatched: false, | |
| // Random scale animation properties | |
| isRandomScaling: false, | |
| randomScaleStartTime: 0, | |
| randomScaleDuration: 0, | |
| randomScalePeakFactor: 1.0, | |
| randomScaleOriginalBase: new THREE.Vector3(1, 1, 1) | |
| }; | |
| return squircleMesh; | |
| } | |
| function initGrid() { | |
| grid = []; | |
| for (let r = 0; r < GRID_SIZE; r++) { | |
| const rowArr = []; | |
| for (let c = 0; c < GRID_SIZE; c++) { | |
| let squircleMesh; | |
| do { | |
| if (squircleMesh) scene.remove(squircleMesh); | |
| squircleMesh = createSquircle(r, c); | |
| } while (checkInitialMatch(squircleMesh, r, c, rowArr)); | |
| scene.add(squircleMesh); | |
| rowArr.push(squircleMesh); | |
| } | |
| grid.push(rowArr); | |
| } | |
| if (checkForAllMatches().length > 0) { | |
| console.log("Initial matches found on board, resolving..."); | |
| handleMatchesAndRefill(); | |
| } | |
| } | |
| function checkInitialMatch(squircleMesh, r, c, currentRowArray) { | |
| const { | |
| colorIndex | |
| } = squircleMesh.userData; | |
| if (c >= 2 && currentRowArray[c - 1]?.userData.colorIndex === colorIndex && currentRowArray[c - 2]?.userData.colorIndex === colorIndex) return true; | |
| if (r >= 2 && grid[r - 1]?.[c]?.userData.colorIndex === colorIndex && grid[r - 2]?.[c]?.userData.colorIndex === colorIndex) return true; | |
| return false; | |
| } | |
| function onWindowResize() { | |
| const aspect = window.innerWidth / window.innerHeight; | |
| const targetHeight = GRID_SIZE * CELL_SIZE * CAMERA_VIEW_FACTOR; | |
| const targetWidth = GRID_SIZE * CELL_SIZE * CAMERA_VIEW_FACTOR; | |
| let frustumHeight, frustumWidth; | |
| if (aspect >= 1) { | |
| frustumHeight = targetHeight; | |
| frustumWidth = frustumHeight * aspect; | |
| } else { | |
| frustumWidth = targetWidth; | |
| frustumHeight = frustumWidth / aspect; | |
| } | |
| camera.left = -frustumWidth / 2; | |
| camera.right = frustumWidth / 2; | |
| camera.top = frustumHeight / 2; | |
| camera.bottom = -frustumHeight / 2; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| async function onClick(event) { // Made async | |
| // Start music on first click | |
| if (!hasMusicStarted) { | |
| await audioSystem.start(); | |
| hasMusicStarted = true; | |
| const muteButton = document.getElementById('muteButton'); | |
| muteButton.style.display = 'block'; | |
| setupMuteButton(); | |
| } | |
| if (isSwapping || isCascading) return; | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObjects(scene.children.filter(obj => obj.isMesh && !obj.userData.isMatched)); | |
| if (intersects.length > 0) { | |
| const clickedSquircle = intersects[0].object; | |
| if (!selectedSquircle) { | |
| selectedSquircle = clickedSquircle; | |
| selectedSquircle.scale.set(1.15, 1.15, 1.1); | |
| // Play SFX on first selection | |
| if (audioSystem && hasMusicStarted && !isMuted) { | |
| const sfxFn = audioSystem.sfxFunctions[Math.floor(Math.random() * audioSystem.sfxFunctions.length)]; | |
| sfxFn(audioSystem.audioCtx.currentTime); | |
| } | |
| } else { | |
| // Deselect previous, or pick new, or swap | |
| selectedSquircle.scale.set(1, 1, 1); // Reset previous selected squircle's scale | |
| if (clickedSquircle === selectedSquircle) { | |
| selectedSquircle = null; | |
| // Play SFX on deselection | |
| if (audioSystem && hasMusicStarted && !isMuted) { | |
| audioSystem.playInvalid(audioSystem.audioCtx.currentTime); // Use "invalid" for deselection | |
| } | |
| return; | |
| } | |
| const { | |
| row: r1, | |
| col: c1 | |
| } = selectedSquircle.userData; | |
| const { | |
| row: r2, | |
| col: c2 | |
| } = clickedSquircle.userData; | |
| if (Math.abs(r1 - r2) + Math.abs(c1 - c2) === 1) { | |
| swapSquircles(selectedSquircle, clickedSquircle); | |
| } else { | |
| // Play SFX when selecting a new squircle after one was already selected | |
| if (audioSystem && hasMusicStarted && !isMuted) { | |
| const sfxFn = audioSystem.sfxFunctions[Math.floor(Math.random() * audioSystem.sfxFunctions.length)]; | |
| sfxFn(audioSystem.audioCtx.currentTime); | |
| } | |
| selectedSquircle = clickedSquircle; | |
| selectedSquircle.scale.set(1.15, 1.15, 1.1); | |
| } | |
| } | |
| } | |
| } | |
| function setupMuteButton() { | |
| const muteButton = document.getElementById('muteButton'); | |
| muteButton.addEventListener('click', () => { | |
| if (audioSystem) { | |
| isMuted = !isMuted; | |
| if (isMuted) { | |
| audioSystem.masterGain.gain.value = 0; | |
| muteButton.textContent = '🔇'; | |
| } else { | |
| // RANDOM SCALE SELECTION | |
| const scaleKeys = Object.keys(audioSystem.scales); | |
| const newScale = scaleKeys[Math.floor(Math.random() * scaleKeys.length)]; | |
| audioSystem.setScale(newScale); // Apply new scale | |
| audioSystem.regenerateAllSequences(); // Regenerate with new scale | |
| audioSystem.masterGain.gain.value = 0.6; | |
| muteButton.textContent = '🔊'; | |
| } | |
| } | |
| }); | |
| } | |
| async function swapSquircles(sq1, sq2, isUndo = false) { | |
| if (isSwapping) return; | |
| isSwapping = true; | |
| const { | |
| row: r1, | |
| col: c1 | |
| } = sq1.userData; | |
| const { | |
| row: r2, | |
| col: c2 | |
| } = sq2.userData; | |
| grid[r1][c1] = sq2; | |
| grid[r2][c2] = sq1; | |
| [sq1.userData.row, sq2.userData.row] = [sq2.userData.row, sq1.userData.row]; | |
| [sq1.userData.col, sq2.userData.col] = [sq2.userData.col, sq1.userData.col]; | |
| const pos1 = sq1.position.clone(); | |
| const pos2 = sq2.position.clone(); | |
| // Play SFX for the swap action itself | |
| if (audioSystem && hasMusicStarted && !isMuted) { | |
| audioSystem.playWhoosh(audioSystem.audioCtx.currentTime); // Or a simple pop | |
| } | |
| await Promise.all([animateToPosition(sq1, pos2, 180), animateToPosition(sq2, pos1, 180)]); | |
| if (!isUndo) { | |
| const matches = checkForAllMatches(); | |
| if (matches.length > 0) { | |
| await handleMatchesAndRefill(); | |
| // SFX for successful match is now handled within handleMatchesAndRefill | |
| if (selectedSquircle) selectedSquircle.scale.set(1, 1, 1); // Reset scale after successful swap/match | |
| selectedSquircle = null; | |
| } else { | |
| score -= SCORE_PENALTY_NO_MATCH; | |
| if (score < 0) score = 0; | |
| updateScoreDisplay(); | |
| // Play SFX for no match / invalid swap | |
| if (audioSystem && hasMusicStarted && !isMuted) { | |
| audioSystem.playInvalid(audioSystem.audioCtx.currentTime); | |
| } | |
| await swapSquircles(sq1, sq2, true); | |
| } | |
| } | |
| isSwapping = false; | |
| } | |
| function animateToPosition(object, targetPosition, duration) { | |
| return new Promise(resolve => { | |
| const startPosition = object.position.clone(); | |
| let startTime = null; | |
| function step(timestamp) { | |
| if (!startTime) startTime = timestamp; | |
| const progress = Math.min((timestamp - startTime) / duration, 1); | |
| object.position.lerpVectors(startPosition, targetPosition, progress * (2 - progress)); | |
| if (progress < 1) requestAnimationFrame(step); | |
| else { | |
| object.position.copy(targetPosition); | |
| resolve(); | |
| } | |
| } | |
| requestAnimationFrame(step); | |
| }); | |
| } | |
| function animateToScale(object, targetScale, duration) { | |
| return new Promise(resolve => { | |
| const startScale = object.scale.clone(); | |
| let startTime = null; | |
| function step(timestamp) { | |
| if (!startTime) startTime = timestamp; | |
| const progress = Math.min((timestamp - startTime) / duration, 1); | |
| object.scale.lerpVectors(startScale, targetScale, progress * (2 - progress)); | |
| if (progress < 1) requestAnimationFrame(step); | |
| else { | |
| object.scale.copy(targetScale); | |
| resolve(); | |
| } | |
| } | |
| requestAnimationFrame(step); | |
| }); | |
| } | |
| function animateWiggle(object, duration = 250, intensityFactor = 1.25) { | |
| return new Promise(resolve => { | |
| // Store the current scale BEFORE starting the wiggle, for restoration | |
| // This ensures the wiggle starts from whatever scale it currently is (e.g. pulsed scale) | |
| const initialScale = object.scale.clone(); | |
| object.userData.originalScale = initialScale.clone(); // Use this to indicate wiggle is active | |
| const peakScale = initialScale.clone().multiplyScalar(intensityFactor); | |
| const troughScale = initialScale.clone().multiplyScalar(1 / intensityFactor * 0.85 + 0.15); | |
| let startTime = null; | |
| const partDuration = duration / 3; | |
| function step(timestamp) { | |
| if (!startTime) startTime = timestamp; | |
| const elapsed = timestamp - startTime; | |
| if (elapsed < partDuration) { | |
| const progress = elapsed / partDuration; | |
| object.scale.lerpVectors(initialScale, peakScale, progress); | |
| } else if (elapsed < partDuration * 2) { | |
| const progress = (elapsed - partDuration) / partDuration; | |
| object.scale.lerpVectors(peakScale, troughScale, progress); | |
| } else if (elapsed < duration) { | |
| const progress = (elapsed - partDuration * 2) / partDuration; | |
| object.scale.lerpVectors(troughScale, initialScale, progress); | |
| } else { | |
| object.scale.copy(initialScale); // Restore to what it was before wiggle | |
| delete object.userData.originalScale; // Clear flag | |
| resolve(); | |
| return; | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| requestAnimationFrame(step); | |
| }); | |
| } | |
| function checkForAllMatches() { | |
| const allMatches = new Set(); | |
| for (let r = 0; r < GRID_SIZE; r++) { | |
| for (let c = 0; c < GRID_SIZE - 2; c++) { | |
| const sq1 = grid[r][c]; | |
| const sq2 = grid[r][c + 1]; | |
| const sq3 = grid[r][c + 2]; | |
| if (!sq1 || !sq2 || !sq3 || sq1.userData.isMatched || sq2.userData.isMatched || sq3.userData.isMatched) continue; | |
| if (sq1.userData.colorIndex === sq2.userData.colorIndex && sq2.userData.colorIndex === sq3.userData.colorIndex) { | |
| let currentMatch = [sq1, sq2, sq3]; | |
| for (let k = c + 3; k < GRID_SIZE; k++) { | |
| const nextSq = grid[r][k]; | |
| if (nextSq && !nextSq.userData.isMatched && nextSq.userData.colorIndex === sq1.userData.colorIndex) { | |
| currentMatch.push(nextSq); | |
| } else break; | |
| } | |
| currentMatch.forEach(sq => allMatches.add(sq)); | |
| c += currentMatch.length - 1; | |
| } | |
| } | |
| } | |
| for (let c = 0; c < GRID_SIZE; c++) { | |
| for (let r = 0; r < GRID_SIZE - 2; r++) { | |
| const sq1 = grid[r][c]; | |
| const sq2 = grid[r + 1][c]; | |
| const sq3 = grid[r + 2][c]; | |
| if (!sq1 || !sq2 || !sq3 || sq1.userData.isMatched || sq2.userData.isMatched || sq3.userData.isMatched) continue; | |
| if (sq1.userData.colorIndex === sq2.userData.colorIndex && sq2.userData.colorIndex === sq3.userData.colorIndex) { | |
| let currentMatch = [sq1, sq2, sq3]; | |
| for (let k = r + 3; k < GRID_SIZE; k++) { | |
| const nextSq = grid[k][c]; | |
| if (nextSq && !nextSq.userData.isMatched && nextSq.userData.colorIndex === sq1.userData.colorIndex) { | |
| currentMatch.push(nextSq); | |
| } else break; | |
| } | |
| currentMatch.forEach(sq => allMatches.add(sq)); | |
| r += currentMatch.length - 1; | |
| } | |
| } | |
| } | |
| return Array.from(allMatches); | |
| } | |
| function scoreMatches(matchedSquircles) { | |
| if (matchedSquircles.length === 0) return; | |
| let basePoints = matchedSquircles.length * 10; | |
| if (matchedSquircles.length > 3) basePoints += (matchedSquircles.length - 3) * 15; | |
| if (matchedSquircles.length > 4) basePoints += (matchedSquircles.length - 4) * 20; | |
| score += basePoints; | |
| } | |
| async function handleMatchesAndRefill() { | |
| isCascading = true; | |
| let matchesFoundThisCascade; | |
| let cascadeCount = 0; | |
| do { | |
| matchesFoundThisCascade = checkForAllMatches(); | |
| if (matchesFoundThisCascade.length > 0) { | |
| cascadeCount++; | |
| scoreMatches(matchesFoundThisCascade); | |
| updateScoreDisplay(); | |
| // >>> AUDIO REACTION TRIGGERED HERE <<< | |
| if (audioSystem && hasMusicStarted) { | |
| audioSystem.addMatchEnergy(matchesFoundThisCascade.length); | |
| // Play "collect" sound for successful match | |
| audioSystem.playCollect(audioSystem.audioCtx.currentTime); | |
| } | |
| // >>> END AUDIO REACTION <<< | |
| matchesFoundThisCascade.forEach(sq => sq.userData.isMatched = true); | |
| const visualEffectPromises = matchesFoundThisCascade.map(async sq => { | |
| // Wait for wiggle to complete before scaling down and removing | |
| await animateWiggle(sq); | |
| const squircleColor = sq.material.color; | |
| createParticleBurst(sq.position.clone(), squircleColor); | |
| return animateToScale(sq, new THREE.Vector3(0.01, 0.01, 0.01), 180); | |
| }); | |
| await Promise.all(visualEffectPromises); | |
| matchesFoundThisCascade.forEach(sq => { | |
| scene.remove(sq); | |
| if (grid[sq.userData.row] && grid[sq.userData.row][sq.userData.col] === sq) { | |
| grid[sq.userData.row][sq.userData.col] = null; | |
| } | |
| }); | |
| const fallPromises = []; | |
| for (let c = 0; c < GRID_SIZE; c++) { | |
| let emptySpaces = 0; | |
| for (let r = GRID_SIZE - 1; r >= 0; r--) { | |
| if (grid[r][c] === null) emptySpaces++; | |
| else if (emptySpaces > 0) { | |
| const sqToFall = grid[r][c]; | |
| grid[r + emptySpaces][c] = sqToFall; | |
| grid[r][c] = null; | |
| sqToFall.userData.row = r + emptySpaces; | |
| const targetY = (sqToFall.userData.row - GRID_SIZE / 2 + 0.5) * CELL_SIZE; | |
| fallPromises.push(animateToPosition(sqToFall, new THREE.Vector3(sqToFall.position.x, targetY, 0), 250 + emptySpaces * 20)); | |
| } | |
| } | |
| } | |
| await Promise.all(fallPromises); | |
| const refillPromises = []; | |
| for (let c = 0; c < GRID_SIZE; c++) { | |
| for (let r = 0; r < GRID_SIZE; r++) { | |
| if (grid[r][c] === null) { | |
| const newSq = createSquircle(r, c); | |
| grid[r][c] = newSq; | |
| scene.add(newSq); | |
| const startY = ((0 - GRID_SIZE / 2 - 1) + 0.5) * CELL_SIZE - r * 0.3; | |
| newSq.position.y = startY; | |
| const targetY = (r - GRID_SIZE / 2 + 0.5) * CELL_SIZE; | |
| newSq.userData.originalY = targetY; | |
| refillPromises.push(animateToPosition(newSq, new THREE.Vector3(newSq.position.x, targetY, 0), 300 + r * 30)); | |
| } | |
| } | |
| } | |
| await Promise.all(refillPromises); | |
| for (let r = 0; r < GRID_SIZE; ++r) | |
| for (let c = 0; c < GRID_SIZE; ++c) | |
| if (grid[r][c]) grid[r][c].userData.isMatched = false; | |
| } | |
| } while (matchesFoundThisCascade.length > 0 && cascadeCount < 10); | |
| isCascading = false; | |
| } | |
| function createParticleBurst(position, baseColor) { | |
| const geometry = new THREE.BufferGeometry(); | |
| const positions = []; | |
| const colors = []; | |
| const velocities = []; | |
| const lifespans = []; | |
| const particleColor = new THREE.Color(baseColor); | |
| for (let i = 0; i < PARTICLE_COUNT_PER_BURST; i++) { | |
| positions.push(position.x, position.y, position.z + 0.1); | |
| const angle = Math.random() * Math.PI * 2; | |
| const speed = Math.random() * PARTICLE_SPREAD + PARTICLE_SPREAD * 0.3; | |
| velocities.push(Math.cos(angle) * speed, Math.sin(angle) * speed, (Math.random() - 0.5) * 0.5); | |
| const c = particleColor.clone(); | |
| c.offsetHSL((Math.random() - 0.5) * 0.1, (Math.random() - 0.5) * 0.1, (Math.random() - 0.5) * 0.1); | |
| colors.push(c.r, c.g, c.b); | |
| lifespans.push(Math.random() * (PARTICLE_LIFESPAN_MAX - PARTICLE_LIFESPAN_MIN) + PARTICLE_LIFESPAN_MIN); | |
| } | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
| geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); | |
| geometry.userData = { | |
| velocities, | |
| lifespans, | |
| ages: new Array(PARTICLE_COUNT_PER_BURST).fill(0) | |
| }; | |
| const material = new THREE.PointsMaterial({ | |
| size: PARTICLE_SIZE, | |
| vertexColors: true, | |
| transparent: true, | |
| opacity: 0.95, | |
| sizeAttenuation: true, | |
| depthWrite: false | |
| }); | |
| const points = new THREE.Points(geometry, material); | |
| points.userData.creationTime = clock.elapsedTime; | |
| points.userData.maxSystemLifespan = PARTICLE_LIFESPAN_MAX + 0.2; | |
| scene.add(points); | |
| activeParticleSystems.push(points); | |
| } | |
| function updateParticleSystems(deltaTime) { | |
| for (let i = activeParticleSystems.length - 1; i >= 0; i--) { | |
| const points = activeParticleSystems[i]; | |
| const geom = points.geometry; | |
| const posAttr = geom.attributes.position; | |
| const { | |
| velocities, | |
| lifespans, | |
| ages | |
| } = geom.userData; | |
| let allParticlesDead = true; | |
| for (let j = 0; j < posAttr.count; j++) { | |
| ages[j] += deltaTime; | |
| if (ages[j] < lifespans[j]) { | |
| allParticlesDead = false; | |
| posAttr.array[j * 3 + 0] += velocities[j * 3 + 0] * deltaTime; | |
| posAttr.array[j * 3 + 1] += velocities[j * 3 + 1] * deltaTime; | |
| posAttr.array[j * 3 + 2] += velocities[j * 3 + 2] * deltaTime; | |
| velocities[j * 3 + 1] -= PARTICLE_GRAVITY * deltaTime; | |
| } else if (ages[j] < lifespans[j] + 0.1) { | |
| allParticlesDead = false; | |
| } | |
| if (ages[j] >= lifespans[j] + 0.1) { | |
| posAttr.array[j * 3 + 1] = -10000; | |
| } | |
| } | |
| posAttr.needsUpdate = true; | |
| const systemElapsedTime = clock.elapsedTime - points.userData.creationTime; | |
| const systemLifeRatio = Math.max(0, 1 - (systemElapsedTime / points.userData.maxSystemLifespan)); | |
| points.material.opacity = systemLifeRatio * 0.95; | |
| points.material.size = PARTICLE_SIZE * Math.sqrt(systemLifeRatio); | |
| if (systemElapsedTime > points.userData.maxSystemLifespan || (allParticlesDead && systemElapsedTime > PARTICLE_LIFESPAN_MAX)) { | |
| scene.remove(points); | |
| geom.dispose(); | |
| points.material.dispose(); | |
| activeParticleSystems.splice(i, 1); | |
| } | |
| points.material.opacity = systemLifeRatio * 0.95; | |
| points.material.size = PARTICLE_SIZE * Math.sqrt(systemLifeRatio); | |
| } | |
| } | |
| function updateScoreDisplay() { | |
| document.getElementById('score').innerText = `Score: ${score}`; | |
| if (audioSystem && hasMusicStarted && score >= lastScaleCheckpoint + 1000) { | |
| lastScaleCheckpoint = Math.floor(score / 1000) * 1000; | |
| const scaleKeys = Object.keys(audioSystem.scales); | |
| let newScale; | |
| do { | |
| newScale = scaleKeys[Math.floor(Math.random() * scaleKeys.length)]; | |
| } while (newScale === audioSystem.currentScale); // Avoid repeating the same scale | |
| audioSystem.setScale(newScale); | |
| audioSystem.regenerateAllSequences(); | |
| console.log("Scale changed to:", newScale); | |
| } | |
| } | |
| // Function to trigger a random scale on an eligible squircle | |
| function triggerRandomScale() { | |
| const eligibleSquircles = []; | |
| grid.forEach(row => { | |
| row.forEach(squircle => { | |
| // A squircle is eligible if it's not: | |
| // matched, swapping, cascading, wiggling, selected, or already randomly scaling | |
| if (squircle && !squircle.userData.isMatched && !isSwapping && !isCascading && | |
| !squircle.userData.originalScale && !squircle.userData.isRandomScaling && | |
| squircle !== selectedSquircle) { | |
| eligibleSquircles.push(squircle); | |
| } | |
| }); | |
| }); | |
| if (eligibleSquircles.length > 0) { | |
| const squircleToAnimate = eligibleSquircles[Math.floor(Math.random() * eligibleSquircles.length)]; | |
| squircleToAnimate.userData.isRandomScaling = true; | |
| squircleToAnimate.userData.randomScaleStartTime = clock.elapsedTime * 1000; // Store time in ms | |
| squircleToAnimate.userData.randomScaleDuration = Math.random() * (RANDOM_SCALE_ANIM_DURATION_MAX - RANDOM_SCALE_ANIM_DURATION_MIN) + RANDOM_SCALE_ANIM_DURATION_MIN; | |
| squircleToAnimate.userData.randomScalePeakFactor = Math.random() * (RANDOM_SCALE_FACTOR_MAX - RANDOM_SCALE_FACTOR_MIN) + RANDOM_SCALE_FACTOR_MIN; | |
| // Capture the squircle's current scale (which would be currentPulseScale or 1.0) | |
| squircleToAnimate.userData.randomScaleOriginalBase = squircleToAnimate.scale.clone(); | |
| } | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const deltaTime = clock.getDelta(); // Time in seconds since last frame | |
| // Debug: Update energy display | |
| if (hasMusicStarted) { | |
| document.getElementById('musicalEnergyValue').textContent = audioSystem.currentMusicalEnergy.toFixed(2); | |
| } | |
| // Update squircle pulse based on audioSystem.lastBeatTime and energy | |
| // Only pulse if music is playing and not explicitly muted | |
| if (hasMusicStarted && !isMuted) { | |
| // Check if a new strong beat has occurred since the last visual update | |
| if (audioSystem.lastBeatTime > lastVisualBeatTime) { | |
| lastVisualBeatTime = audioSystem.lastBeatTime; | |
| // Trigger a pulse: scale up, magnitude depends on musical energy | |
| const pulseMagnitude = 0.05 + audioSystem.currentMusicalEnergy * 0.15; | |
| currentPulseScale = 1.0 + pulseMagnitude; | |
| } | |
| // Decay pulse over time | |
| if (currentPulseScale > 1.0) { | |
| currentPulseScale -= deltaTime * 1.0; | |
| if (currentPulseScale < 1.0) currentPulseScale = 1.0; // Clamp at original scale | |
| } | |
| } else { | |
| // No pulse if music is not playing or muted | |
| currentPulseScale = 1.0; | |
| } | |
| // Debug: Update pulse scale display | |
| document.getElementById('pulseScaleValue').textContent = currentPulseScale.toFixed(2); | |
| // Trigger random scale occasionally | |
| if (clock.elapsedTime > nextRandomScaleTriggerTime) { | |
| triggerRandomScale(); | |
| nextRandomScaleTriggerTime = clock.elapsedTime + (Math.random() * (RANDOM_SCALE_TRIGGER_INTERVAL_MAX - RANDOM_SCALE_TRIGGER_INTERVAL_MIN) + RANDOM_SCALE_TRIGGER_INTERVAL_MIN); | |
| } | |
| // Apply animations to squircles based on priority | |
| grid.forEach(row => { | |
| row.forEach(squircle => { | |
| // Ensure squircle exists and is not involved in core game mechanics | |
| if (squircle && !squircle.userData.isMatched && !isSwapping && !isCascading) { | |
| // Priority 1: Wiggle animation (triggered by match) | |
| if (squircle.userData.originalScale) { | |
| // Wiggle animation is actively controlling this squircle's scale | |
| // No action needed here, `animateWiggle` handles it. | |
| } | |
| // Priority 2: Selected squircle | |
| else if (squircle === selectedSquircle) { | |
| // Always maintain the selected scale for the currently selected squircle | |
| squircle.scale.set(1.15, 1.15, 1.1); | |
| } | |
| // Priority 3: NEW Random scale animation | |
| else if (squircle.userData.isRandomScaling) { | |
| const elapsed = clock.elapsedTime * 1000 - squircle.userData.randomScaleStartTime; // Convert to ms | |
| const duration = squircle.userData.randomScaleDuration; | |
| const peakFactor = squircle.userData.randomScalePeakFactor; | |
| const startScale = squircle.userData.randomScaleOriginalBase.x; // Just need the x/y factor | |
| let currentFactor = startScale; | |
| if (elapsed < duration / 2) { // Scale up phase | |
| const progress = elapsed / (duration / 2); | |
| currentFactor = startScale + (peakFactor - startScale) * progress; | |
| } else if (elapsed < duration) { // Scale down phase | |
| const progress = (elapsed - duration / 2) / (duration / 2); | |
| currentFactor = peakFactor - (peakFactor - startScale) * progress; | |
| } else { // Animation complete, reset state | |
| squircle.userData.isRandomScaling = false; | |
| // Once random animation is complete, it reverts to the normal pulse scale | |
| squircle.scale.set(currentPulseScale, currentPulseScale, 1.0); | |
| } | |
| // Apply the calculated current factor only if the random animation is still active | |
| if (squircle.userData.isRandomScaling) { | |
| squircle.scale.set(currentFactor, currentFactor, 1.0); | |
| } | |
| } | |
| // Priority 4: Default musical pulse | |
| else { | |
| squircle.scale.set(currentPulseScale, currentPulseScale, 1.0); | |
| } | |
| } | |
| }); | |
| }); | |
| updateParticleSystems(deltaTime); | |
| renderer.render(scene, camera); | |
| } | |
| // Initialize the game | |
| init(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment