feat: physical contact overhaul — brawls, clinches, counter-attacks

50%+ of fight time is now physical contact between fighters:

- Brawl exchanges: both fighters close to center, trade 4-13 rapid
  blows with sparks at contact point, positional pushing, intensity
  scaling. Winner lands 65% of hits.
- Clinch combos: attacker rushes in, grapples at close range for
  3-11 sustained hits with defender counter-blocking (30% chance).
  Ends with a shove-apart.
- Counter-attacks: after a choreography hit, defender retaliates
  with 2-3 quick hits (25% chance on non-final exchanges).
- Exchange count increased from 2-4 to 3-7, scaling with round
  number and intensity. Later rounds are longer, more brutal.
- Physical exchange chance: 35-70% per non-final exchange,
  increasing with intensity and exchange number.
- Reduced dodge chance (12% vs 15%) for more contact, fewer misses.
- Shorter pauses between exchanges for tighter pacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 17:01:06 +00:00
co-authored by Claude Opus 4.6
parent a59b0748a7
commit ba1deab0f6
+184 -32
View File
@@ -9135,6 +9135,161 @@ export async function createFightScene(config: FightSceneConfig) {
fB.play('idle') 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) { async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) {
const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[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 // Super-speed scales with intensity
const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0)) 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 // 3-7 exchanges — later rounds and higher intensity get more, ensuring sustained fights
const exchangeCount = 2 + Math.floor(Math.random() * 3) 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 // Hyperdetail scales with intensity
const hyperDetail = Math.random() < (0.05 + intensity * 0.45) const hyperDetail = Math.random() < (0.05 + intensity * 0.45)
const savedScaleAX = fA?.scale.x 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) // 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 attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide
exchangeCritical = false exchangeCritical = false
// Occasionally play a silly sound on non-decisive hits // Occasionally play a comedy sound on non-decisive hits
if (Math.random() < 0.15) sfxRandomSilly() if (Math.random() < 0.15) { Math.random() < 0.5 ? sfxRandomComedy() : sfxRandomSilly() }
} else { } else {
// Draw: alternate // Draw: alternate
attackerSide = ex % 2 === 0 ? 'a' : 'b' attackerSide = ex % 2 === 0 ? 'a' : 'b'
@@ -9469,40 +9625,36 @@ export async function createFightScene(config: FightSceneConfig) {
// Random scanline glitch during exchanges (25%) // Random scanline glitch during exchanges (25%)
if (Math.random() < 0.25) scanlineGlitch(0.2) 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) { if (!aWon && !bWon && isLastExchange) {
// Draw: clash in the middle // Draw: brawl in the middle (sustained contact)
const fA = k.get('fighterA')[0] await this._brawlExchange(null, intensity)
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')
}
} else { } else {
// Occasionally the defender dodges (non-final exchanges only) // Decide exchange type: choreography, brawl, or clinch
if (!isLastExchange && Math.random() < 0.15) { // 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') await this.playDodge(attackerSide === 'a' ? 'b' : 'a')
sfxDodge() sfxDodge()
if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing() if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing()
} else { } else {
// CHOREOGRAPHY: signature move from the pool
await this.playAttack(attackerSide, choreo, exchangeCritical) 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) // 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 // Clean up speed lines