From a59b0748a7fe183f025f65cf1832d60ca77930d1 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 16:55:14 +0000 Subject: [PATCH] feat: 30 comedy sound effects, replace 75% round-end TTS with SFX Comedy sounds (all procedural Web Audio, no samples): - Vine Boom, Air Horn (MLG), Bruh, Fart, Record Scratch - Rubber Chicken, Squeaky Toy, Wet Slap, Bone Crack, Cartoon Run - Rim Shot (ba dum tss), Slide Whistle Up/Down, Yippee, Ding - Taco Bell Bong, Windows Error, Meme Thud - Sad Trombone, Emotional Damage, Dun Dun Dunnnn - Fail Horn, Price Is Right Fail, Buzzer, Sad Violin - Mission Failed, Crickets Wiring: - Round-end narration: 75% comedy SFX, 25% TTS (was 100% TTS) - Critical rounds get Emotional Damage, draws get random fail - Showboat loop: 40% chance of comedy sound punctuation after each - KO finish: 50% chance of comedy fail sound on loser - sfxRandomSilly expanded with comedy sounds - New exports: sfxRandomComedy, sfxRandomFail Also adds bulletTimeAttack combat choreography + ultimateBulletTimeBarrage (tier 3+ ultimate) to fight system. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/FightViewer.vue | 15 +- frontend/src/game/FightScene.ts | 153 ++++++- frontend/src/game/sounds.ts | 507 ++++++++++++++++++++++-- 3 files changed, 618 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 37e0365..beacc21 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -9,6 +9,7 @@ import { setMusicIntensity, stopAllAudio, setMasterMute, isMasterMuted, ensureAudioContext, speakQuestion, speakAnswer, speakNarration, + sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, } from '../game/sounds' interface Round { @@ -411,7 +412,19 @@ async function _doReplay() { if (round.narration) { logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' }) scrollLog() - if (doTTS) await speakNarration(round.narration) + // 75% comedy SFX instead of TTS narration — much funnier and faster + if (doTTS && Math.random() < 0.25) { + await speakNarration(round.narration) + } else { + // Play a comedy sound that matches the round result + if (isCritical) { + sfxEmotionalDamage() + } else if (!aWon && !bWon) { + sfxRandomFail() + } else { + sfxRandomComedy() + } + } await sleep(insanityMode.value ? 30 : 200) } diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 85f5dc8..db37bf9 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -15,6 +15,13 @@ import { announceCreatorEntrance, announceCreatorRound, announceCreatorKO, announceCreatorWin, announceCreatorLose, announceCreatorMorph, announceCreatorCameo, announceCreatorTaunt, announceCreatorDevastating, + sfxRandomComedy, sfxRandomFail, + sfxVineBoom, sfxAirHorn, sfxBruh, sfxFart, sfxRecordScratch, + sfxRubberChicken, sfxSqueakyToy, sfxWetSlap, sfxBoneCrack, sfxCartoonRun, + sfxRimShot, sfxSlideWhistleUp, sfxSlideWhistleDown, sfxYippee, sfxDing, + sfxTacoBellBong, sfxWindowsError, sfxMemeThud, + sfxSadTrombone, sfxFailHorn, sfxPriceIsRightFail, sfxBuzzer, + sfxMissionFailed, sfxCrickets, sfxSadViolin, sfxEmotionalDamage, sfxDunDunDun, } from './sounds' export interface FightSceneConfig { @@ -80,7 +87,7 @@ const spriteAnims = { // Challenge type -> themed choreography (shown ~60% of the time) + generic fallbacks const CHALLENGE_THEMED: Record = { - speed_blitz: { themed: ['afterimageDash', 'lightningRush', 'rapidFlurry', 'tornadoSpin', 'corkscrewDive', 'warpDrive', 'backflipKick'], generic: ['dashPunch', 'multiHit', 'fullScreenDash', 'dashThrough', 'zoomRush', 'katanaCombo', 'cycloneKick', 'helicopterArms', 'breakdanceSweep'] }, + speed_blitz: { themed: ['afterimageDash', 'lightningRush', 'rapidFlurry', 'tornadoSpin', 'corkscrewDive', 'warpDrive', 'backflipKick', 'bulletTimeAttack'], generic: ['dashPunch', 'multiHit', 'fullScreenDash', 'dashThrough', 'zoomRush', 'katanaCombo', 'cycloneKick', 'helicopterArms', 'breakdanceSweep'] }, riddle: { themed: ['riddleBarrage', 'portalPunch', 'cloneStrike', 'mindBlast', 'hexCurse'], generic: ['teleportStrike', 'projectile', 'zoomRush', 'whipCrack', 'bombThrow', 'captchaTrap', 'glitchBeam'] }, code_golf: { themed: ['golfClubSmash', 'golfCartDrive', 'stackOverflow', 'segfault', 'blueScreen'], generic: ['projectile', 'flyingKick', 'dashPunch', 'rapidFlurry', 'hammerSmash', 'bodySlam', 'homeRunSwing'] }, roast_battle: { themed: ['fireBreath', 'dragonBreath', 'fireball', 'lavaSplash', 'heatVision', 'volcanoErupt'], generic: ['gunBurst', 'jetpackDive', 'multiHit', 'zoomRush', 'laserBeam', 'chainsawRev', 'rocketLauncher', 'dynamiteBlast'] }, @@ -95,8 +102,8 @@ const CHALLENGE_THEMED: Record magic_duel: { themed: ['kamehameha', 'spiritBomb', 'arcaneBarrage', 'darkVoid', 'holySmite', 'shadowClone', 'hexCurse'], generic: ['fireball', 'iceLance', 'thunderStrike', 'portalPunch', 'crystalShards', 'scrollBlast', 'potionThrow'] }, sports_showdown: { themed: ['homeRunSwing', 'slapShot', 'servingAce', 'fieldGoalKick', 'bodyCheck', 'soccerKick', 'basketballDunk'], generic: ['baseballBat', 'tennisRacket', 'hockeyStick', 'bowlingBallRoll', 'tennisBallVolley', 'dropKick'] }, nature_clash: { themed: ['treeSmash', 'icebergDrop', 'lavaSplash', 'tornadoFling', 'tsunamiWave', 'avalanche', 'earthquakeStrike'], generic: ['vineWhip', 'sandstorm', 'thornBarrage', 'pollenCloud', 'windSlash', 'meteorStrike'] }, - space_war: { themed: ['blackHole', 'photonTorpedo', 'tractor_beam', 'warpDrive', 'alienAbduction', 'ionCannon', 'cosmicRay'], generic: ['laserSword', 'plasmaSword', 'asteroidBelt', 'satelliteLaser', 'plasmaBeam', 'solarFlare'] }, - hack_battle: { themed: ['hackerAttack', 'bugSwarm', 'stackOverflow', 'segfault', 'blueScreen', 'malwareInject'], generic: ['fourOhFour', 'ctrlAltDelete', 'aiUprising', 'captchaTrap', 'popupSpam', 'dataStream', 'glitchBeam'] }, + space_war: { themed: ['blackHole', 'photonTorpedo', 'tractor_beam', 'warpDrive', 'alienAbduction', 'ionCannon', 'cosmicRay', 'bulletTimeAttack'], generic: ['laserSword', 'plasmaSword', 'asteroidBelt', 'satelliteLaser', 'plasmaBeam', 'solarFlare'] }, + hack_battle: { themed: ['hackerAttack', 'bugSwarm', 'stackOverflow', 'segfault', 'blueScreen', 'malwareInject', 'bulletTimeAttack'], generic: ['fourOhFour', 'ctrlAltDelete', 'aiUprising', 'captchaTrap', 'popupSpam', 'dataStream', 'glitchBeam'] }, meme_war: { themed: ['selfieStrike', 'dabAttack', 'flossAttack', 'yeetThrow', 'tPoseAssert', 'emojiBarrage', 'memeBeam'], generic: ['rubberChicken', 'fingerGuns', 'micDrop', 'ratioAttack', 'capThrow', 'touchGrass', 'noScope'] }, animal_kingdom: { themed: ['sharkBite', 'bearSwipe', 'eagleDive', 'bullCharge', 'gorillaSlam', 'wolfPack', 'batSwarm'], generic: ['snakeLunge', 'scorpionSting', 'crabPinch', 'spiderWeb', 'beeSwarm', 'catScratch', 'dogPile', 'dolphinFlip'] }, demolition: { themed: ['dynamiteBlast', 'c4Detonation', 'nukeStrike', 'grenadeBlast', 'rocketLauncher', 'volcanoErupt'], generic: ['fireworksBurst', 'partyPopper', 'pinataSmash', 'cherryBomb', 'confettiCannon', 'anvilDrop', 'pianoDrop'] }, @@ -108,13 +115,13 @@ const CHALLENGE_THEMED: Record // Tier-gated ultimate move pools — cumulative (tier 3 bot can use tier 2+3 moves) const TIER_ULTIMATES: Record = { 2: ['ultimateRapidBarrage', 'ultimateOrbitalStrike', 'ultimateCloneSurround', 'ultimateGatlingFury', 'ultimateEarthquake', 'ultimateBouncingFury', 'ultimateTripleBeam', 'ultimateMegaThrow', 'ultimateTornadoGrab', 'ultimateMeteorShower'], - 3: ['ultimateScreenNuke', 'ultimateBlackHoleVortex', 'ultimateDimensionalRift', 'ultimateTimeSlow', 'ultimateSummonArmy', 'ultimateHellfireRain', 'ultimateCrystalCage', 'ultimateGigaDrill', 'ultimateSuperSlam', 'ultimateAvalancheDrop'], + 3: ['ultimateScreenNuke', 'ultimateBlackHoleVortex', 'ultimateDimensionalRift', 'ultimateTimeSlow', 'ultimateSummonArmy', 'ultimateHellfireRain', 'ultimateCrystalCage', 'ultimateGigaDrill', 'ultimateSuperSlam', 'ultimateAvalancheDrop', 'ultimateBulletTimeBarrage'], 4: ['ultimateFinaleRush', 'ultimateMeteorCrash', 'ultimateScreenShatter', 'ultimateGravityReverse', 'ultimateInfiniteCombo', 'ultimatePlasmaStorm', 'ultimateFrozenCoffin', 'ultimateLavaGeyser', 'ultimateChainGrab', 'ultimateRocketBarrage'], 5: ['ultimateArmageddon', 'ultimateOmegaBeam', 'ultimateBeyondTheScreen', 'ultimateMatrixDodge', 'ultimateWorldEnder', 'ultimateAbsoluteZero', 'ultimateSolarCannon', 'ultimatePhantomStrike', 'ultimateGodPunch', 'ultimateVoidCollapse'], } // The Creator — exclusive moves (50% ultimate chance, 100% on crits, custom pool) -const CREATOR_MOVES = ['bitcoinRain', 'lightningInvoice', 'satoshiStrike', 'hashRateOverload', 'blockchainSlam', 'creatorMode', 'morphStrike', 'divineIntervention', 'bitcoinSwarm', 'satoshiTornado', 'hodlWave', 'bitcoinBarrage'] +const CREATOR_MOVES = ['bitcoinRain', 'lightningInvoice', 'satoshiStrike', 'hashRateOverload', 'blockchainSlam', 'creatorMode', 'morphStrike', 'divineIntervention', 'bitcoinSwarm', 'satoshiTornado', 'hodlWave', 'bitcoinBarrage', 'bulletTimeAttack'] const CREATOR_ULTIMATES = ['ultimateGenesisBlock', 'ultimateTheHalving', 'ultimateFullNodeCrush', 'ultimateSatoshiVision'] function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string { @@ -172,6 +179,7 @@ function pickChoreography(challengeType: string, isCritical: boolean, _round: nu 'alienAbduction', 'tsunamiWave', 'earthquakeStrike', 'dragonBreath', 'pianoDrop', 'fridgeDrop', 'volcanoErupt', 'warpDrive', 'dubstepCannon', 'tornadoSpin', 'trampolineBounce', 'watermelonBomb', 'nftRugPull', 'blueScreen', 'yeetThrow', + 'bulletTimeAttack', ] return critMoves[Math.floor(Math.random() * critMoves.length)] } @@ -5507,6 +5515,110 @@ export async function createFightScene(config: FightSceneConfig) { const ultimateSuperSlam = makeGrab('up', 250) const ultimateAvalancheDrop = makeExplosion(30, 30, '#aaeeff', 0.2, '#ffffff', true) + // ULTIMATE BULLET TIME BARRAGE — full slow-mo cinematic: dodge rain of projectiles, afterimage rush, multi-hit counter + async function ultimateBulletTimeBarrage(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { + // Phase 1: World goes dark, heavy slow-mo overlay + const dim = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#000022')), k.opacity(0), k.z(25)]) + await k.tween(0, 0.55, 0.2, (v) => { dim.opacity = v }) + scanlineGlitch(0.15) + glitchRGB(0.1) + + // Phase 2: Defender fires a barrage of slow-mo bullets + def.play('special') + for (let i = 0; i < 8; i++) { + sfxGunshot() + const bx = def.pos.x - dir * 20 + const by = def.pos.y - 35 + (Math.random() - 0.5) * 30 + const bullet = k.add([ + k.rect(12, 3), + k.pos(bx, by), + k.color(safeColor(k, '#ff4444')), + k.opacity(0.85), + k.z(26), + k.anchor('center'), + ]) + // Bullet trace + const trace = k.add([ + k.rect(60, 1), + k.pos(bx + dir * 30, by), + k.color(safeColor(k, '#ff6644')), + k.opacity(0.4), + k.z(25), + ]) + setTimeout(() => { if (trace.exists()) trace.destroy() }, 300) + // Bullet travels slowly + k.tween(bullet.pos.x, atk.pos.x + (Math.random() - 0.5) * 30, 0.3, (v) => { if (bullet.exists()) bullet.pos.x = v }).then(() => { if (bullet.exists()) bullet.destroy() }) + await k.wait(0.05) + } + + // Phase 3: Attacker dodges each bullet with slow-mo lean (afterimages cascade) + atk.play('idle') + for (let lean = 0; lean < 3; lean++) { + const leanDir = lean % 2 === 0 ? -1 : 1 + const ghost = k.add([ + k.rect(FRAME_SIZE * Math.abs(atk.scale.x) * 0.7, FRAME_SIZE * Math.abs(atk.scale.y)), + k.pos(atk.pos.x, atk.pos.y), + k.color(safeColor(k, '#ffd700')), + k.opacity(0.3), + k.z(24), + k.anchor('bot'), + ]) + setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 350) + await k.tween(atk.pos.y, GROUND_Y + leanDir * 15, 0.08, (v) => { atk.pos.y = v }, k.easings.easeOutQuad) + await k.tween(atk.pos.y, GROUND_Y, 0.08, (v) => { atk.pos.y = v }, k.easings.easeInQuad) + } + await k.wait(0.1) + + // Phase 4: Time RESUMES — golden flash, attacker blazes forward + dim.destroy() + screenFlash('#ffd700', 0.15) + sfxZoomWhoosh() + sfxCritical() + + // Afterimage rush trail (8 ghosts across the screen) + const contactX = origDX - dir * 40 + for (let i = 0; i < 8; i++) { + const gx = origAX + ((contactX - origAX) * i / 8) + const ghost = k.add([ + k.rect(FRAME_SIZE * Math.abs(atk.scale.x) * 0.6, FRAME_SIZE * Math.abs(atk.scale.y) * 0.9), + k.pos(gx, GROUND_Y), + k.color(safeColor(k, '#ffd700')), + k.opacity(0.35 - i * 0.04), + k.z(9), + k.anchor('bot'), + ]) + setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 300) + } + await k.tween(atk.pos.x, contactX, 0.06, (v) => { atk.pos.x = v }, k.easings.easeInQuad) + + // Phase 5: 8-hit slow-mo combo — each hit with sparks and screen shake + for (let i = 0; i < 8; i++) { + atk.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special') + sfxPunch() + def.play('hit') + k.shake(5 + i * 2) + spawnSparks(def.pos.x + (Math.random() - 0.5) * 15, def.pos.y - 15 - Math.random() * 25, 5, i % 2 === 0 ? '#ffd700' : '#ffffff') + await k.wait(0.04) + } + + // Phase 6: Final devastating blow — full screen effects + sfxExplosion() + k.shake(30) + screenFlash('#ffd700', 0.25) + def.play('knockback') + spawnShockwave(def.pos.x, GROUND_Y, '#ffd700') + spawnShockwave(def.pos.x + dir * 30, GROUND_Y, '#ffaa00') + spawnSparks(def.pos.x, def.pos.y - 25, 25, '#ffd700') + glitchRGB(0.3) + + await k.wait(0.4) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + ]) + atk.play('idle') + } + // --- TIER 4+ ULTIMATES (Platinum) — full screen, multi-phase spectacles --- async function ultimateFinaleRush(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { // Full-screen dash through, reverse, dash again, then combo finish @@ -6506,7 +6618,7 @@ export async function createFightScene(config: FightSceneConfig) { pocketCannon, grappleFlurry, bodySlam, pinballCombo, suplex, // Themed golfClubSmash, fireBreath, riddleBarrage, cloneStrike, - coinShower, penStab, mathAttack, trapCardAttack, afterimageDash, + coinShower, penStab, mathAttack, trapCardAttack, afterimageDash, bulletTimeAttack, // --- NEW: Food throws --- pizzaSlam, bananaFling, pieSmash, hotdogWhip, watermelonBomb, sushiBarrage, burgerToss, iceCreamFling, tacoStorm, donutBarrage, @@ -6589,7 +6701,7 @@ export async function createFightScene(config: FightSceneConfig) { ultimateBouncingFury, ultimateTripleBeam, ultimateMegaThrow, ultimateTornadoGrab, ultimateMeteorShower, // Tier 3+ ultimateScreenNuke, ultimateBlackHoleVortex, ultimateDimensionalRift, ultimateTimeSlow, ultimateSummonArmy, - ultimateHellfireRain, ultimateCrystalCage, ultimateGigaDrill, ultimateSuperSlam, ultimateAvalancheDrop, + ultimateHellfireRain, ultimateCrystalCage, ultimateGigaDrill, ultimateSuperSlam, ultimateAvalancheDrop, ultimateBulletTimeBarrage, // Tier 4+ ultimateFinaleRush, ultimateMeteorCrash, ultimateScreenShatter, ultimateGravityReverse, ultimateInfiniteCombo, ultimatePlasmaStorm, ultimateFrozenCoffin, ultimateLavaGeyser, ultimateChainGrab, ultimateRocketBarrage, @@ -7531,7 +7643,7 @@ export async function createFightScene(config: FightSceneConfig) { await k.tween(judge.pos.y, judgeOrigY, 0.08, (v) => { judge.pos.y = v }, k.easings.easeInQuad) judge.play(d % 2 === 0 ? 'call_left' : 'call_right') } - sfxRandomSilly() + sfxRandomComedy() judge.play('idle') } else if (funnyAction === 2) { // Holds up a 10 score card @@ -7604,7 +7716,7 @@ export async function createFightScene(config: FightSceneConfig) { flag.onUpdate(() => { flag.pos.y = judge.pos.y - 30 + Math.sin(k.time() * 10) * 3 }) - sfxRandomSilly() + sfxRandomComedy() await k.wait(1.5) flag.destroy(); pole.destroy() } else { @@ -8478,6 +8590,8 @@ export async function createFightScene(config: FightSceneConfig) { const idx = Math.floor(Math.random() * pool.length) try { await pool[idx](f, homeX, homeY, sx, sy, dir) + // 40% chance of comedy sound punctuation after each showboat + if (Math.random() < 0.4) sfxRandomComedy() } catch { break } // Restore state after each showboat const ff = k.get(tag)[0] @@ -9386,7 +9500,7 @@ export async function createFightScene(config: FightSceneConfig) { if (!isLastExchange && Math.random() < 0.15) { await this.playDodge(attackerSide === 'a' ? 'b' : 'a') sfxDodge() - sfxBoing() + if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing() } else { await this.playAttack(attackerSide, choreo, exchangeCritical) } @@ -9792,7 +9906,7 @@ export async function createFightScene(config: FightSceneConfig) { await k.tween(loser.pos.y, loserOrigY, 0.3, (v) => { loser.pos.y = v }, k.easings.easeInQuad) sfxSplat(); k.shake(15); screenFlash('#ffe135', 0.15) spawnShockwave(loser.pos.x, GROUND_Y, '#ffe135') - winner.play('win'); sfxRandomSilly() + winner.play('win'); sfxRandomComedy() } else if (finishStyle === 5) { // ANVIL DROP — cartoon anvil from the sky fatalityTagline = 'HEAVY METAL!' @@ -9821,7 +9935,7 @@ export async function createFightScene(config: FightSceneConfig) { await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) for (let i = 0; i < 8; i++) { winner.play(i % 2 === 0 ? 'attack' : 'kick') - sfxBoing(); sfxRandomSilly() + sfxBoing(); sfxRandomComedy() loser.play('hit'); k.shake(3 + i) for (const p of chicken) { p.pos.x = loser.pos.x + (Math.random() - 0.5) * 10; p.pos.y = loser.pos.y - 30 + (Math.random() - 0.5) * 10 } spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 40 - Math.random() * 20, ['SQUEAK!', 'BAWK!', 'HONK!', 'SQUAWK!'][i % 4], '#ffdd44') @@ -9915,7 +10029,7 @@ export async function createFightScene(config: FightSceneConfig) { await k.wait(0.3) for (let i = 0; i < 6; i++) { loser.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special') - sfxRandomSilly(); screenFlash(discoColors[i], 0.04) + sfxRandomComedy(); screenFlash(discoColors[i], 0.04) await k.wait(0.1) } sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.2) @@ -10060,7 +10174,7 @@ export async function createFightScene(config: FightSceneConfig) { announceFast('Rev it up!') const mower = spawnWeaponProp('lawn_mower', winner.pos.x + dir * 10, GROUND_Y - 8, dir < 0, 16) winner.play('special'); sfxPowerUp() - for (let i = 0; i < 3; i++) { sfxRandomSilly(); await k.wait(0.1) } + for (let i = 0; i < 3; i++) { sfxRandomComedy(); await k.wait(0.1) } sfxZoomWhoosh() for (const p of mower) { k.tween(p.pos.x, loser.pos.x + dir * 40, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad) @@ -10109,7 +10223,7 @@ export async function createFightScene(config: FightSceneConfig) { loser.opacity = Math.random() > 0.5 ? 1 : 0.2 loser.pos.x += (Math.random() - 0.5) * 15 loser.play(['idle', 'attack', 'kick', 'hit', 'special'][Math.floor(Math.random() * 5)]) - screenFlash('#00ff00', 0.02); sfxRandomSilly() + screenFlash('#00ff00', 0.02); sfxRandomFail() await k.wait(0.06) } screenFlash('#0000ff', 0.3); sfxFail(); k.shake(20) @@ -10175,7 +10289,7 @@ export async function createFightScene(config: FightSceneConfig) { for (let i = 0; i < 30; i++) { spawnProp(winner.pos.x + dir * 30, winner.pos.y - 25, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 80 - Math.random() * 60, 4, 3, confettiColors[i % 8], 400 + Math.random() * 200) } - sfxBoing(); sfxRandomSilly() + sfxBoing(); sfxRandomComedy() loser.play('hit'); k.shake(10); await k.wait(0.15) loser.play('knockback'); sfxCritical(); k.shake(15) await k.tween(loser.pos.x, loser.pos.x + dir * 50, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad) @@ -10343,7 +10457,7 @@ export async function createFightScene(config: FightSceneConfig) { sfxPowerUp() const moves = ['attack', 'kick', 'special', 'kick', 'attack', 'special'] as const for (let i = 0; i < moves.length; i++) { - winner.play(moves[i]); sfxRandomSilly() + winner.play(moves[i]); sfxRandomComedy() spawnSparks(winner.pos.x, winner.pos.y - 30, 3, ['#ff00ff', '#00ffff', '#ffff00'][i % 3]) await k.wait(0.08) } @@ -10366,7 +10480,7 @@ export async function createFightScene(config: FightSceneConfig) { announceSilly('What is that SMELL!?') const cushion = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y - 5), k.color(safeColor(k, '#ff6688')), k.z(10), k.opacity(0.8)]) sfxBoing(); await k.wait(0.2) - sfxWomp(); sfxRandomSilly(); k.shake(8) + sfxWomp(); sfxRandomComedy(); k.shake(8) await k.tween(20, 40, 0.15, (v) => { cushion.radius = v }, k.easings.easeOutQuad) sfxExplosion() for (let i = 0; i < 12; i++) { @@ -10466,6 +10580,7 @@ export async function createFightScene(config: FightSceneConfig) { k.shake(18); sfxExplosion() await k.wait(0.3) loser.play('ko') + if (Math.random() < 0.5) setTimeout(() => sfxRandomFail(), 200) await k.wait(0.4) // pause for visual impact before fatality voice const winnerArch = winningSide === 'a' ? botA.archetype : botB.archetype const loserArch = winningSide === 'a' ? botB.archetype : botA.archetype @@ -10635,7 +10750,7 @@ export async function createFightScene(config: FightSceneConfig) { 18, ['#ff2d7b', '#00f0ff', '#ffe14d', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i] ) - if (i % 2 === 0) sfxRandomSilly() + if (i % 2 === 0) sfxRandomComedy() }, i * 200) } await k.wait(0.7) diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 47eea98..4967664 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -1384,10 +1384,411 @@ export function sfxFail() { // Pick a random silly sound export function sfxRandomSilly() { - const fns = [sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp] + const fns = [ + sfxBoing, sfxBonk, sfxSplat, sfxZap, sfxCoin, sfxSlideUp, + sfxVineBoom, sfxAirHorn, sfxFart, sfxRubberChicken, sfxSqueakyToy, + sfxCartoonRun, sfxWetSlap, sfxBoneCrack, sfxRimShot, sfxDing, + ] fns[Math.floor(Math.random() * fns.length)]() } +// Random comedy sound — heavier on meme sounds, for taunts/showboats/round ends +export function sfxRandomComedy() { + const fns = [ + sfxVineBoom, sfxAirHorn, sfxBruh, sfxFart, sfxRecordScratch, + sfxRubberChicken, sfxSqueakyToy, sfxWetSlap, sfxBoneCrack, + sfxCartoonRun, sfxRimShot, sfxSlideWhistleUp, sfxSlideWhistleDown, + sfxYippee, sfxDing, sfxTacoBellBong, sfxWindowsError, sfxMemeThud, + sfxBoing, sfxBonk, sfxSplat, + ] + fns[Math.floor(Math.random() * fns.length)]() +} + +// Random fail sound — for missed attacks, dodges, KOs on the loser +export function sfxRandomFail() { + const fns = [ + sfxSadTrombone, sfxFailHorn, sfxPriceIsRightFail, sfxBuzzer, + sfxMissionFailed, sfxCrickets, sfxWindowsError, sfxSadViolin, + sfxWomp, sfxEmotionalDamage, sfxDunDunDun, + ] + fns[Math.floor(Math.random() * fns.length)]() +} + +// === COMEDY SOUND EFFECTS === +// Procedural comedy/meme sounds inspired by classic internet soundboard culture + +// VINE BOOM — iconic deep bass boom with heavy sub + distortion +export function sfxVineBoom() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(45, 0.4, d, t, 0.5) + bodyThump(90, 0.3, d, t, 0.35) + fmImpact(150, 5, 0.25, d, t) + noise(0.06, d, t) + reverbTail(0.5, d, t + 0.1) +} + +// AIR HORN (MLG) — obnoxious ascending horn blasts +export function sfxAirHorn() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 3; i++) { + const bt = t + i * 0.15 + tone(520 + i * 30, 'sawtooth', 0.12, d, bt) + tone(523 + i * 30, 'square', 0.12, d, bt) + tone(1046 + i * 60, 'sawtooth', 0.08, d, bt) + } + reverbTail(0.3, d, t + 0.4) +} + +// BRUH — deep bass impact with vowel-like formant resonance +export function sfxBruh() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(80, 0.3, d, t, 0.4) + const bufSize = Math.floor(c.sampleRate * 0.25) + const buf = c.createBuffer(1, bufSize, c.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1 + const src = c.createBufferSource(); src.buffer = buf + const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 300; bp.Q.value = 5 + const g = c.createGain() + g.gain.setValueAtTime(0.25, t) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.25) + src.connect(bp); bp.connect(g); g.connect(d) + src.start(t); src.stop(t + 0.25) + bodyThump(55, 0.15, d, t + 0.05, 0.3) + reverbTail(0.3, d, t + 0.1) +} + +// FART — rumbling low-frequency modulated noise +export function sfxFart() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + const duration = 0.3 + Math.random() * 0.3 + const osc = c.createOscillator(); osc.type = 'sawtooth' + osc.frequency.setValueAtTime(80 + Math.random() * 40, t) + osc.frequency.exponentialRampToValueAtTime(40 + Math.random() * 30, t + duration) + const lfo = c.createOscillator(); lfo.type = 'square' + lfo.frequency.setValueAtTime(20 + Math.random() * 30, t) + const lfoG = c.createGain(); lfoG.gain.value = 40 + lfo.connect(lfoG); lfoG.connect(osc.frequency) + const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 150 + Math.random() * 100; bp.Q.value = 2 + const g = c.createGain() + g.gain.setValueAtTime(0.3, t) + g.gain.setValueAtTime(0.25, t + duration * 0.3) + g.gain.exponentialRampToValueAtTime(0.001, t + duration) + osc.connect(bp); bp.connect(g); g.connect(d) + osc.start(t); osc.stop(t + duration) + lfo.start(t); lfo.stop(t + duration) + noise(duration * 0.6, d, t) +} + +// RECORD SCRATCH — vinyl stop effect +export function sfxRecordScratch() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(3000, 100, 'sawtooth', 0.15, d) + sweep(2500, 80, 'square', 0.12, d) + noise(0.1, d, t) + highSnap(4000, 0.03, d, t) + setTimeout(() => sweep(200, 400, 'sine', 0.08, d), 100) +} + +// RUBBER CHICKEN — squeaky honk +export function sfxRubberChicken() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + sweep(300, 1200, 'sine', 0.1, d) + const osc = c.createOscillator(); osc.type = 'sine' + osc.frequency.setValueAtTime(1100, t + 0.1) + osc.frequency.setValueAtTime(1200, t + 0.15) + osc.frequency.exponentialRampToValueAtTime(600, t + 0.35) + const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 20 + const lfoG = c.createGain(); lfoG.gain.value = 80 + lfo.connect(lfoG); lfoG.connect(osc.frequency) + const g = c.createGain() + g.gain.setValueAtTime(0.25, t + 0.1) + g.gain.exponentialRampToValueAtTime(0.001, t + 0.35) + osc.connect(g); g.connect(d) + osc.start(t + 0.1); osc.stop(t + 0.35) + lfo.start(t + 0.1); lfo.stop(t + 0.35) +} + +// SQUEAKY TOY — quick toy squeeze +export function sfxSqueakyToy() { + const d = getSfxDest() + sweep(400, 1800, 'sine', 0.06, d) + sweep(1800, 600, 'sine', 0.1, d) + setTimeout(() => sweep(500, 1500, 'sine', 0.05, d), 120) +} + +// WET SLAP — meaty slap with low-end body +export function sfxWetSlap() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + highSnap(3000, 0.02, d, t) + highSnap(1200, 0.03, d, t) + bodyThump(150, 0.08, d, t, 0.3) + noise(0.06, d, t) + fmImpact(300, 2, 0.08, d, t) + reverbTail(0.15, d, t + 0.03) +} + +// BONE CRACK — sharp multi-frequency crack sequence +export function sfxBoneCrack() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 3; i++) { + const ct = t + i * 0.04 + highSnap(3000 + i * 1500, 0.015, d, ct) + fmImpact(800 + i * 400, 4, 0.03, d, ct) + noise(0.02, d, ct) + } + bodyThump(100, 0.04, d, t + 0.08, 0.15) +} + +// CARTOON RUN — rapid bongo/pitter-patter footsteps +export function sfxCartoonRun() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 8; i++) { + const st = t + i * 0.05 + bodyThump(200 + (i % 2) * 80, 0.03, d, st, 0.15) + highSnap(2000 + Math.random() * 1000, 0.01, d, st) + } +} + +// RIM SHOT — ba dum tss +export function sfxRimShot() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(180, 0.08, d, t, 0.2) + bodyThump(140, 0.08, d, t + 0.15, 0.2) + highSnap(4000, 0.04, d, t + 0.3) + noise(0.15, d, t + 0.3) + fmImpact(2000, 3, 0.12, d, t + 0.3) + bodyThump(200, 0.04, d, t + 0.3, 0.15) +} + +// SLIDE WHISTLE UP — cartoon ascend +export function sfxSlideWhistleUp() { + const d = getSfxDest() + sweep(300, 2500, 'sine', 0.4, d) + sweep(305, 2510, 'sine', 0.4, d) +} + +// SLIDE WHISTLE DOWN — cartoon descend +export function sfxSlideWhistleDown() { + const d = getSfxDest() + sweep(2500, 200, 'sine', 0.5, d) + sweep(2510, 205, 'sine', 0.5, d) +} + +// YIPPEE — ascending cheerful chirps +export function sfxYippee() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + const notes = [523, 659, 784, 1047] + for (let i = 0; i < notes.length; i++) { + tone(notes[i], 'triangle', 0.08, d, t + i * 0.07) + tone(notes[i] * 2, 'sine', 0.06, d, t + i * 0.07) + } +} + +// DING — bright bell +export function sfxDing() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + fmImpact(1319, 1.5, 0.3, d, t) + tone(1319, 'triangle', 0.25, d, t) + tone(2638, 'sine', 0.15, d, t) +} + +// TACO BELL BONG — deep resonant bell +export function sfxTacoBellBong() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + fmImpact(130, 2.5, 0.8, d, t) + fmImpact(261, 3, 0.6, d, t) + tone(130, 'sine', 0.6, d, t) + bodyThump(65, 0.3, d, t, 0.2) + reverbTail(0.7, d, t + 0.1) +} + +// WINDOWS ERROR — two-tone error chord +export function sfxWindowsError() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + tone(440, 'square', 0.15, d, t) + tone(466, 'square', 0.15, d, t) + tone(220, 'triangle', 0.2, d, t) + setTimeout(() => { + tone(349, 'square', 0.2, d) + tone(175, 'triangle', 0.25, d) + }, 180) +} + +// MEME THUD — super heavy bass drop +export function sfxMemeThud() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + bodyThump(35, 0.5, d, t, 0.5) + bodyThump(70, 0.4, d, t, 0.4) + noise(0.08, d, t) + fmImpact(100, 6, 0.3, d, t) + reverbTail(0.6, d, t + 0.1) +} + +// SAD TROMBONE (Wa Wa Wa Waaaa) — classic comedy fail +export function sfxSadTrombone() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + const notes: [number, number][] = [[466, 0.25], [440, 0.25], [415, 0.25], [392, 0.7]] + let off = 0 + for (let i = 0; i < notes.length; i++) { + const [freq, dur] = notes[i] + tone(freq, 'sawtooth', dur, d, t + off) + tone(freq * 0.5, 'triangle', dur, d, t + off) + if (i === 3) { + const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq + const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 5 + const lfoG = c.createGain(); lfoG.gain.value = 8 + lfo.connect(lfoG); lfoG.connect(osc.frequency) + const g = c.createGain() + g.gain.setValueAtTime(0.2, t + off) + g.gain.exponentialRampToValueAtTime(0.001, t + off + dur) + osc.connect(g); g.connect(d) + osc.start(t + off); osc.stop(t + off + dur) + lfo.start(t + off); lfo.stop(t + off + dur) + } + off += dur + } + reverbTail(0.5, d, t + off) +} + +// EMOTIONAL DAMAGE — dramatic descending brass stab +export function sfxEmotionalDamage() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + tone(220, 'sawtooth', 0.3, d, t) + tone(277, 'sawtooth', 0.3, d, t) + tone(330, 'sawtooth', 0.3, d, t) + bodyThump(110, 0.2, d, t, 0.3) + noise(0.08, d, t) + fmImpact(600, 4, 0.2, d, t) + reverbTail(0.5, d, t + 0.1) +} + +// DUN DUN DUNNNNN — dramatic reveal stinger +export function sfxDunDunDun() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + tone(147, 'sawtooth', 0.2, d, t) + tone(147, 'triangle', 0.2, d, t) + bodyThump(73, 0.15, d, t, 0.25) + tone(147, 'sawtooth', 0.2, d, t + 0.25) + bodyThump(73, 0.15, d, t + 0.25, 0.25) + tone(110, 'sawtooth', 0.8, d, t + 0.5) + tone(110, 'triangle', 0.8, d, t + 0.5) + tone(55, 'sine', 0.8, d, t + 0.5) + bodyThump(55, 0.5, d, t + 0.5, 0.3) + reverbTail(0.8, d, t + 0.6) +} + +// FAIL HORN — game show wrong answer horn +export function sfxFailHorn() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + tone(311, 'sawtooth', 0.3, d, t) + tone(156, 'sawtooth', 0.3, d, t) + tone(233, 'square', 0.3, d, t) + setTimeout(() => { + tone(277, 'sawtooth', 0.5, d) + tone(139, 'sawtooth', 0.5, d) + tone(208, 'square', 0.5, d) + bodyThump(70, 0.3, d, undefined, 0.2) + }, 350) + reverbTail(0.5, d, t + 0.8) +} + +// PRICE IS RIGHT FAIL — descending tuba + sad brass +export function sfxPriceIsRightFail() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + const notes = [262, 247, 233, 220, 208, 196] + for (let i = 0; i < notes.length; i++) { + const nt = t + i * 0.18 + tone(notes[i], 'sawtooth', 0.2, d, nt) + tone(notes[i] * 0.5, 'triangle', 0.2, d, nt) + } + bodyThump(65, 0.4, d, t + notes.length * 0.18, 0.3) + reverbTail(0.5, d, t + notes.length * 0.18) +} + +// BUZZER — harsh game show wrong answer +export function sfxBuzzer() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + tone(150, 'square', 0.4, d, t) + tone(153, 'square', 0.4, d, t) + tone(75, 'square', 0.4, d, t) + noise(0.15, d, t) + bodyThump(60, 0.2, d, t, 0.2) +} + +// SAD VIOLIN — thin high vibrato melody +export function sfxSadViolin() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + const notes: [number, number][] = [[659, 0.3], [622, 0.3], [587, 0.4], [554, 0.5]] + let off = 0 + for (const [freq, dur] of notes) { + const osc = c.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq + const lfo = c.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 6 + const lfoG = c.createGain(); lfoG.gain.value = 6 + lfo.connect(lfoG); lfoG.connect(osc.frequency) + const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq * 2; bp.Q.value = 3 + const g = c.createGain() + g.gain.setValueAtTime(0.15, t + off) + g.gain.exponentialRampToValueAtTime(0.001, t + off + dur) + osc.connect(bp); bp.connect(g); g.connect(d) + osc.start(t + off); osc.stop(t + off + dur) + lfo.start(t + off); lfo.stop(t + off + dur) + off += dur + } +} + +// MISSION FAILED — dark two-note descending stab +export function sfxMissionFailed() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + tone(165, 'sawtooth', 0.3, d, t) + tone(196, 'sawtooth', 0.3, d, t) + tone(247, 'sawtooth', 0.3, d, t) + bodyThump(82, 0.2, d, t, 0.2) + setTimeout(() => { + tone(139, 'sawtooth', 0.6, d) + tone(165, 'sawtooth', 0.6, d) + tone(208, 'sawtooth', 0.6, d) + bodyThump(70, 0.4, d, undefined, 0.25) + reverbTail(0.6, d) + }, 400) +} + +// CRICKETS — awkward silence chirps +export function sfxCrickets() { + const d = getSfxDest() + const c = getCtx(); const t = c.currentTime + for (let i = 0; i < 3; i++) { + const ct = t + i * 0.4 + tone(4200, 'sine', 0.03, d, ct) + tone(4200, 'sine', 0.03, d, ct + 0.05) + tone(4500, 'sine', 0.02, d, ct + 0.03) + tone(4500, 'sine', 0.02, d, ct + 0.08) + } +} + // === BACKGROUND MUSIC === // Rich, full arcade fighting soundtrack with 6 simultaneous layers @@ -1619,7 +2020,8 @@ const track1: MusicTrack = { 330,415,494,415, 330,415,494,659, 494,659,494,415, 330,494,415,330, 440,523,659,523, 440,659,523,440, 659,523,440,330, 440,523,659,0, ], - chords: [[220,262,330],[175,220,262],[262,330,392],[196,247,294],[220,262,330],[294,349,440],[165,208,247],[220,262,330]], + // I-V-vi-IV in C major (classic pop anthem) + chords: [[262,330,392],[196,247,294],[220,262,330],[175,220,262],[262,330,392],[196,247,294],[220,262,330],[175,220,262]], drums: [ 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,0,4,0, 1,3,0,3, 2,0,3,3, 1,3,0,3, 2,0,3,0, 1,3,0,3, 2,3,3,0, 1,3,0,3, 2,3,4,3, @@ -1661,7 +2063,8 @@ const track2: MusicTrack = { 494,622,740,622, 494,622,740,988, 740,988,740,622, 494,740,622,494, 330,392,494,392, 330,494,392,330, 494,392,330,247, 330,392,494,0, ], - chords: [[165,196,247],[131,165,196],[196,247,294],[147,185,220],[165,196,247],[220,262,330],[247,311,370],[165,196,247]], + // i-VII-VI-v in E minor (Andalusian descent) + chords: [[165,196,247],[147,185,220],[131,165,196],[123,147,185],[165,196,247],[131,165,196],[147,185,220],[165,196,247]], drums: [ 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,4,0, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,3,3,3, 2,3,4,3, @@ -1703,7 +2106,8 @@ const track3: MusicTrack = { 220,277,330,277, 220,277,330,440, 330,440,330,277, 220,330,277,220, 294,349,440,349, 294,440,349,294, 440,349,294,220, 294,349,440,0, ], - chords: [[147,175,220],[233,294,349],[175,220,262],[131,165,196],[147,175,220],[196,233,294],[220,277,330],[147,175,220]], + // i-III-VI-V in D minor (dark dramatic) + chords: [[147,175,220],[175,220,262],[233,294,349],[220,277,330],[147,175,220],[175,220,262],[233,294,349],[147,175,220]], drums: [ 1,0,0,3, 0,0,2,0, 1,0,0,3, 0,0,2,0, 1,0,0,3, 0,0,2,0, 1,0,3,3, 2,0,0,0, 1,0,0,3, 0,0,2,0, 1,0,3,3, 2,0,0,0, 1,0,0,3, 0,0,2,0, 1,0,0,3, 2,0,4,0, @@ -1745,7 +2149,8 @@ const track4: MusicTrack = { 392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392, 262,311,392,311, 262,392,311,262, 392,311,262,196, 262,311,392,0, ], - chords: [[262,311,392],[208,262,311],[156,196,233],[233,294,349],[262,311,392],[175,208,262],[196,247,294],[262,311,392]], + // i-iv-VII-III in C minor (aggressive power) + chords: [[131,156,196],[175,208,262],[233,294,349],[156,196,233],[131,156,196],[175,208,262],[233,294,349],[131,156,196]], drums: [ 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,4,0, 1,3,1,3, 2,0,3,3, 1,3,0,3, 2,3,3,0, 1,3,1,3, 2,3,3,0, 1,3,3,3, 2,3,4,3, @@ -1787,7 +2192,8 @@ const track5: MusicTrack = { 392,494,587,494, 392,494,587,784, 587,784,587,494, 392,587,494,392, 440,523,659,523, 440,659,523,440, 659,523,440,330, 440,523,659,0, ], - chords: [[220,262,330],[165,196,247],[175,220,262],[131,165,196],[220,262,330],[147,175,220],[196,247,294],[220,262,330]], + // i-v-iv-VII in A minor (grinding stubborn) + chords: [[220,262,330],[165,196,247],[147,175,220],[196,247,294],[220,262,330],[165,196,247],[147,175,220],[220,262,330]], drums: [ 1,0,0,3, 2,0,0,3, 1,0,0,3, 2,0,0,3, 1,0,0,3, 2,0,0,3, 1,0,3,0, 2,0,0,0, 1,0,0,3, 2,0,3,0, 1,0,0,3, 2,0,0,3, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,4,0, @@ -1829,7 +2235,8 @@ const track6: MusicTrack = { 294,370,440,370, 294,370,440,587, 440,587,440,370, 294,440,370,294, 196,233,294,233, 196,294,233,196, 294,233,196,147, 196,233,294,0, ], - chords: [[196,233,294],[156,196,233],[233,294,349],[175,220,262],[196,233,294],[131,156,196],[147,185,220],[196,233,294]], + // i-VI-iv-V in G minor (melancholic beauty) + chords: [[196,233,294],[156,196,233],[131,156,196],[147,185,220],[196,233,294],[156,196,233],[147,185,220],[196,233,294]], drums: [ 1,0,0,0, 2,0,0,3, 1,0,0,0, 2,0,0,0, 1,0,0,3, 2,0,0,0, 1,0,0,3, 2,0,0,0, 1,0,0,3, 2,0,0,0, 1,0,3,0, 2,0,0,3, 1,0,0,3, 2,0,0,3, 1,0,3,0, 2,0,4,0, @@ -1871,7 +2278,8 @@ const track7: MusicTrack = { 494,622,740,622, 494,622,740,988, 740,988,740,622, 494,740,622,494, 330,392,494,392, 330,494,392,330, 494,392,330,247, 330,392,494,0, ], - chords: [[165,196,247],[196,247,294],[147,185,220],[131,165,196],[165,196,247],[220,262,330],[247,311,370],[165,196,247]], + // I-IV-vi-V in G major (uplifting pop) + chords: [[196,247,294],[131,165,196],[165,196,247],[147,185,220],[196,247,294],[131,165,196],[165,196,247],[196,247,294]], drums: [ 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,4,0, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,3,3,3, 2,3,4,0, @@ -1913,7 +2321,8 @@ const track8: MusicTrack = { 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 349,415,523,415, 349,523,415,349, 523,415,349,262, 349,415,523,0, ], - chords: [[175,208,262],[139,175,208],[208,262,311],[156,196,233],[175,208,262],[233,277,349],[131,165,196],[175,208,262]], + // i-VII-VI-VII in F minor (oscillating tension) + chords: [[175,208,262],[156,196,233],[139,175,208],[156,196,233],[175,208,262],[139,175,208],[156,196,233],[175,208,262]], drums: [ 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,3,0, 1,3,1,3, 2,0,3,0, 1,3,0,3, 2,0,4,0, 1,3,1,3, 2,3,3,0, 1,3,0,3, 2,0,3,3, 1,3,1,3, 2,0,3,3, 1,3,3,3, 2,3,4,3, @@ -1955,7 +2364,8 @@ const track9: MusicTrack = { 262,330,392,330, 262,330,392,523, 392,523,392,330, 262,392,330,262, 294,349,440,349, 294,440,349,294, 440,349,294,220, 294,349,440,0, ], - chords: [[147,175,220],[196,233,294],[233,294,349],[220,277,330],[147,175,220],[175,220,262],[131,165,196],[147,175,220]], + // i-iv-V-i → i-VI-VII-i in D minor (tension/release) + chords: [[147,175,220],[196,233,294],[220,277,330],[147,175,220],[147,175,220],[233,294,349],[262,330,392],[147,175,220]], drums: [ 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,3, 2,0,3,0, 1,0,3,3, 2,0,3,0, 1,3,3,0, 2,0,3,3, 1,0,3,0, 2,3,3,0, 1,3,3,3, 2,3,4,3, @@ -1997,7 +2407,8 @@ const track10: MusicTrack = { 349,415,523,415, 349,415,523,698, 523,698,523,415, 349,523,415,349, 392,494,587,494, 392,587,494,392, 587,494,392,294, 392,494,587,0, ], - chords: [[262,311,392],[156,196,233],[233,294,349],[196,247,294],[262,311,392],[208,262,311],[175,208,262],[196,247,294]], + // I-vi-ii-V in Eb major (triumphant jazz-pop) + chords: [[156,196,233],[131,156,196],[175,208,262],[233,294,349],[156,196,233],[131,156,196],[175,208,262],[233,294,349]], drums: [ 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,3,0, 1,0,3,0, 2,0,4,0, 1,0,3,0, 2,0,3,3, 1,0,3,0, 2,3,3,0, 1,3,3,0, 2,3,3,3, 1,3,1,3, 2,3,4,3, @@ -2016,34 +2427,38 @@ function playMusicBar() { const c = getCtx() getMusicDelay() // ensure delay is created - // Dynamic tempo: subtle intensity shift (+10 BPM at max) - const dynamicBpm = activeTrack.bpm + currentIntensity * 10 + // Dynamic tempo: subtle intensity shift (+8 BPM at max) + const dynamicBpm = activeTrack.bpm + currentIntensity * 8 const beat = 60 / dynamicBpm const now = c.currentTime + 0.05 const bar = barIndex % BARS const off = bar * STEPS const step = beat / 2 - // Switch tracks at musical boundaries (full 8-bar cycles) - const chillTracks = ALL_TRACKS.filter(t => t.bpm < 160) - const midTracks = ALL_TRACKS.filter(t => t.bpm >= 160 && t.bpm < 175) - const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 175) + // === VERSE / CHORUS DYNAMICS === + // Bars 0-3 are "verse" (sparser), bars 4-7 are "chorus" (climactic, full) + const isChorus = bar >= 4 + const leadThreshold = isChorus ? 0.15 : 0.4 + const arpThreshold = isChorus ? 0.25 : 0.55 + + // Switch tracks at musical boundaries — NEVER repeat the same track const switchEvery = currentIntensity > 0.7 ? 8 : currentIntensity > 0.4 ? 16 : 24 if (barIndex > 0 && barIndex % switchEvery === 0) { - const pickFrom = (pool: MusicTrack[]) => { - const others = pool.filter(t => t !== activeTrack) - return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : pool[0] - } const prevTrack = activeTrack - if (currentIntensity > 0.7) { - activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS) - } else if (currentIntensity < 0.3) { - activeTrack = pickFrom(chillTracks.length > 0 ? chillTracks : ALL_TRACKS) - } else if (Math.random() < 0.5) { - activeTrack = pickFrom(midTracks.length > 0 ? midTracks : ALL_TRACKS) + // Always pick from ALL tracks, excluding current — guarantees no repeat + const others = ALL_TRACKS.filter(t => t !== activeTrack) + // Prefer tracks matching intensity mood + const chill = others.filter(t => t.bpm < 155) + const mid = others.filter(t => t.bpm >= 155 && t.bpm < 168) + const intense = others.filter(t => t.bpm >= 168) + if (currentIntensity > 0.7 && intense.length > 0) { + activeTrack = intense[Math.floor(Math.random() * intense.length)] + } else if (currentIntensity < 0.3 && chill.length > 0) { + activeTrack = chill[Math.floor(Math.random() * chill.length)] + } else if (mid.length > 0 && Math.random() < 0.5) { + activeTrack = mid[Math.floor(Math.random() * mid.length)] } else { - // Full random for maximum variety - activeTrack = pickFrom(ALL_TRACKS) + activeTrack = others[Math.floor(Math.random() * others.length)] } // Smooth crossfade on track switch if (activeTrack !== prevTrack && musicGain) { @@ -2054,35 +2469,53 @@ function playMusicBar() { } } - // Chord pad for the whole bar + // Chord pad for the whole bar — louder during chorus const barDur = STEPS * step - chordPad(activeTrack.chords[bar].map(f => f * 2), barDur, musicGain, now) + const padVol = isChorus ? 0.06 : 0.04 + const padFreqs = activeTrack.chords[bar].map(f => f * 2) + const padC = getCtx() + for (const freq of padFreqs) { + const osc = padC.createOscillator() + osc.type = 'triangle' + osc.frequency.value = freq + const g = padC.createGain() + g.gain.setValueAtTime(padVol, now) + g.gain.setValueAtTime(padVol, now + barDur * 0.8) + g.gain.exponentialRampToValueAtTime(0.001, now + barDur) + osc.connect(g); g.connect(musicGain) + osc.start(now); osc.stop(now + barDur) + } for (let i = 0; i < STEPS; i++) { const t = now + i * step const idx = off + i const nd = step - 0.01 - // Layer 1: Bass — always plays but gets thicker with intensity + // Layer 1: Bass — always plays if (activeTrack.bass[idx] > 0) thickBass(activeTrack.bass[idx], nd, musicGain, t) - // Layer 2: FM lead — fades in above 0.3 intensity - if (activeTrack.lead[idx] > 0 && currentIntensity > 0.3) { + // Layer 2: FM lead — verse: needs higher intensity; chorus: plays freely + if (activeTrack.lead[idx] > 0 && currentIntensity > leadThreshold) { fmLead(activeTrack.lead[idx], nd * 0.8, musicGain, t) } - // Layer 3: Arpeggio — fades in above 0.5 intensity - if (activeTrack.arp[idx] > 0 && currentIntensity > 0.5) { + // Layer 3: Arpeggio — verse: needs high intensity; chorus: plays freely + if (activeTrack.arp[idx] > 0 && currentIntensity > arpThreshold) { chorusTone(activeTrack.arp[idx], 'triangle', nd * 0.6, musicGain, t, 0.04 + currentIntensity * 0.04) } - // Layer 4: Drums — always, but intensity controls density + // Layer 4: Drums — intensity controls density const d = activeTrack.drums[idx] if (d === 1) kick(musicGain, t) if (d === 2 && currentIntensity > 0.2) snare(musicGain, t) if (d === 3) hihat(musicGain, t, false) if (d === 4 && currentIntensity > 0.4) hihat(musicGain, t, true) + // Chorus: extra snare ghost notes on off-beats for energy + if (isChorus && currentIntensity > 0.6 && d === 0 && i % 4 === 2 && Math.random() < 0.3) { + snare(musicGain, t) + } + // Extra open hihat at high intensity (musical, not chaotic) if (currentIntensity > 0.85 && i === 14 && Math.random() < 0.3) { hihat(musicGain, t, true)