Skip to content

Instantly share code, notes, and snippets.

@semanticentity
Created July 15, 2025 05:30
Show Gist options
  • Select an option

  • Save semanticentity/73c8e7f78e390d9f7a97b7d201bfa86a to your computer and use it in GitHub Desktop.

Select an option

Save semanticentity/73c8e7f78e390d9f7a97b7d201bfa86a to your computer and use it in GitHub Desktop.
GridClash: Match 3 Game
<!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