diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index db37bf9..92ab93f 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -9135,6 +9135,161 @@ export async function createFightScene(config: FightSceneConfig) { fB.play('idle') }, + // === PHYSICAL CONTACT SYSTEMS === + // Brawl exchange: fighters close distance and trade rapid blows at close range + async _brawlExchange(winningSide: 'a' | 'b' | null, intensity: number) { + const fA = k.get('fighterA')[0] + const fB = k.get('fighterB')[0] + if (!fA || !fB) return + + // Close distance — both fighters move to center + const midX = W / 2 + const contactA = midX - 30 + const contactB = midX + 30 + sfxZoomWhoosh() + await Promise.all([ + k.tween(fA.pos.x, contactA, 0.12, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, contactB, 0.12, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + + // Trade blows — number of hits scales with intensity + const hitCount = 4 + Math.floor(intensity * 6) + Math.floor(Math.random() * 3) // 4-13 hits + const anims = ['attack', 'kick', 'special', 'attack', 'kick'] as const + const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#00f0ff', '#ff2d2d', '#39ff14', '#ffffff', '#b83dff'] + + for (let i = 0; i < hitCount; i++) { + const aAttacks = winningSide === 'a' ? Math.random() < 0.65 : winningSide === 'b' ? Math.random() < 0.35 : Math.random() < 0.5 + const attacker = aAttacks ? fA : fB + const defender = aAttacks ? fB : fA + const anim = anims[i % anims.length] + + attacker.play(anim) + sfxPunch() + defender.play('hit') + k.shake(2 + Math.floor(intensity * 3)) + + // Sparks at contact point + const sparkX = (contactA + contactB) / 2 + (Math.random() - 0.5) * 20 + const sparkY = GROUND_Y - 20 - Math.random() * 30 + spawnSparks(sparkX, sparkY, 3, hitColors[i % hitColors.length]) + + // Small positional shifts during brawl — fighters push each other + const pushDir = aAttacks ? 1 : -1 + defender.pos.x += pushDir * (3 + Math.random() * 4) + + // Timing varies — faster hits at higher intensity + await k.wait(0.04 + (1 - intensity) * 0.04) + } + + // After brawl: both stagger apart + fA.play('idle'); fB.play('idle') + await Promise.all([ + k.tween(fA.pos.x, HOME_A, 0.2, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, HOME_B, 0.2, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + }, + + // Clinch combo: grapple at close range, one fighter dominates with sustained contact + async _clinchCombo(attackerSide: 'a' | 'b', intensity: number) { + const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] + const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] + if (!attacker || !defender) return + + const dir = attackerSide === 'a' ? 1 : -1 + const origAX = attackerSide === 'a' ? HOME_A : HOME_B + const origDX = attackerSide === 'a' ? HOME_B : HOME_A + const clinchX = (origAX + origDX) / 2 + + // Rush in to clinch + sfxZoomWhoosh() + attacker.play('attack') + await k.tween(attacker.pos.x, clinchX - dir * 25, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) + await k.tween(defender.pos.x, clinchX + dir * 25, 0.08, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) + + // Clinch phase — sustained close-range hits with body contact + const clinchHits = 3 + Math.floor(intensity * 5) + Math.floor(Math.random() * 3) // 3-11 hits + const movePool = ['attack', 'kick', 'special'] as const + + for (let i = 0; i < clinchHits; i++) { + const move = movePool[i % movePool.length] + attacker.play(move) + sfxPunch() + await k.wait(0.03) + defender.play('hit') + k.shake(2 + Math.floor(i * 0.5)) + + // Defender gets pushed back slightly, attacker follows + defender.pos.x += dir * 4 + attacker.pos.x += dir * 3 + + // Sparks at the contact point between fighters + spawnSparks( + (attacker.pos.x + defender.pos.x) / 2, + GROUND_Y - 25 - Math.random() * 20, + 2 + Math.floor(intensity * 3), + i % 2 === 0 ? '#ff2d7b' : '#ffcc00', + ) + + // Defender occasionally blocks or fights back (30% chance) + if (Math.random() < 0.3) { + defender.play(movePool[Math.floor(Math.random() * movePool.length)]) + sfxBlock() + attacker.play('hit') + k.shake(3) + attacker.pos.x -= dir * 5 + await k.wait(0.04) + } + + await k.wait(0.04 + (1 - intensity) * 0.03) + } + + // Break apart — attacker shoves defender away + sfxKick() + defender.play('knockback') + k.shake(6 + Math.floor(intensity * 6)) + spawnSparks(defender.pos.x, defender.pos.y - 25, 8, '#ff2d2d') + await k.tween(defender.pos.x, origDX + dir * 30, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) + attacker.play('idle') + + await Promise.all([ + k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad), + k.tween(defender.pos.x, origDX, 0.25, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad), + ]) + defender.play('idle') + }, + + // Counter-attack: after a choreography hit, the defender retaliates briefly + async _counterAttack(defenderSide: 'a' | 'b') { + const defender = k.get(defenderSide === 'a' ? 'fighterA' : 'fighterB')[0] + const attacker = k.get(defenderSide === 'a' ? 'fighterB' : 'fighterA')[0] + if (!defender || !attacker) return + + const dir = defenderSide === 'a' ? 1 : -1 + const origDX = defenderSide === 'a' ? HOME_A : HOME_B + + // Quick retaliation — defender rushes in + defender.play('attack') + sfxZoomWhoosh() + const contactX = attacker.pos.x - dir * 35 + await k.tween(defender.pos.x, contactX, 0.08, (v) => { defender.pos.x = v }, k.easings.easeInQuad) + + // 2-3 quick counter hits + const counterHits = 2 + Math.floor(Math.random() * 2) + for (let i = 0; i < counterHits; i++) { + defender.play(i % 2 === 0 ? 'attack' : 'kick') + sfxPunch() + attacker.play('hit') + k.shake(4) + spawnSparks(attacker.pos.x + (Math.random() - 0.5) * 15, attacker.pos.y - 20 - Math.random() * 15, 3, '#00f0ff') + await k.wait(0.05) + } + + // Return + attacker.play('idle') + defender.play('idle') + await k.tween(defender.pos.x, origDX, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) + }, + async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) { const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0] @@ -9170,8 +9325,9 @@ export async function createFightScene(config: FightSceneConfig) { // Super-speed scales with intensity const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0)) - // Always 2-4 exchanges for variety — intensity controls winner dominance, not count - const exchangeCount = 2 + Math.floor(Math.random() * 3) + // 3-7 exchanges — later rounds and higher intensity get more, ensuring sustained fights + const roundBonus = Math.min(2, Math.floor(event.round / 2)) + const exchangeCount = 3 + roundBonus + Math.floor(Math.random() * 3) + (intensity > 0.5 ? 1 : 0) // Hyperdetail scales with intensity const hyperDetail = Math.random() < (0.05 + intensity * 0.45) const savedScaleAX = fA?.scale.x @@ -9364,8 +9520,8 @@ export async function createFightScene(config: FightSceneConfig) { // Loser hits back less at high intensity (more one-sided domination) attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide exchangeCritical = false - // Occasionally play a silly sound on non-decisive hits - if (Math.random() < 0.15) sfxRandomSilly() + // Occasionally play a comedy sound on non-decisive hits + if (Math.random() < 0.15) { Math.random() < 0.5 ? sfxRandomComedy() : sfxRandomSilly() } } else { // Draw: alternate attackerSide = ex % 2 === 0 ? 'a' : 'b' @@ -9469,40 +9625,36 @@ export async function createFightScene(config: FightSceneConfig) { // Random scanline glitch during exchanges (25%) if (Math.random() < 0.25) scanlineGlitch(0.2) - // Play the exchange + // Play the exchange — mix of choreography, brawls, clinches for ~50% physical contact if (!aWon && !bWon && isLastExchange) { - // Draw: clash in the middle - const fA = k.get('fighterA')[0] - const fB = k.get('fighterB')[0] - if (fA && fB) { - const midX = W / 2 - sfxClash() - await Promise.all([ - k.tween(fA.pos.x, midX - 30, 0.15, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), - k.tween(fB.pos.x, midX + 30, 0.15, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), - ]) - fA.play('attack'); fB.play('attack') - await k.wait(0.1) - fA.play('hit'); fB.play('hit') - k.shake(8) - spawnSparks(midX, GROUND_Y - 50, 12, '#ffffff') - screenFlash('#ffffff', 0.08) - sfxBonk() - await k.wait(0.3) - await Promise.all([ - k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), - k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), - ]) - fA.play('idle'); fB.play('idle') - } + // Draw: brawl in the middle (sustained contact) + await this._brawlExchange(null, intensity) } else { - // Occasionally the defender dodges (non-final exchanges only) - if (!isLastExchange && Math.random() < 0.15) { + // Decide exchange type: choreography, brawl, or clinch + // Brawl/clinch chance increases with later exchanges and higher intensity + const physicalChance = 0.35 + intensity * 0.2 + (ex > 1 ? 0.15 : 0) + const exchangeType = Math.random() + + if (!isLastExchange && exchangeType < physicalChance * 0.5) { + // BRAWL: both fighters close distance and trade rapid blows + await this._brawlExchange(aWon ? 'a' : bWon ? 'b' : null, intensity) + } else if (!isLastExchange && exchangeType < physicalChance) { + // CLINCH COMBO: attacker grapples defender at close range + await this._clinchCombo(attackerSide, intensity) + } else if (!isLastExchange && Math.random() < 0.12) { + // DODGE: defender dodges (reduced from 15% to 12% — more contact, fewer misses) await this.playDodge(attackerSide === 'a' ? 'b' : 'a') sfxDodge() if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing() } else { + // CHOREOGRAPHY: signature move from the pool await this.playAttack(attackerSide, choreo, exchangeCritical) + + // COUNTER-ATTACK: defender fights back after getting hit (25% chance on non-final exchanges) + if (!isLastExchange && !exchangeCritical && Math.random() < 0.25) { + await k.wait(0.08) + await this._counterAttack(attackerSide === 'a' ? 'b' : 'a') + } } } @@ -9513,7 +9665,7 @@ export async function createFightScene(config: FightSceneConfig) { } // Brief pause between exchanges (much shorter in super-speed) - if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.3 + Math.random() * 0.3) + if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.2 + Math.random() * 0.25) } // Clean up speed lines