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:
co-authored by
Claude Opus 4.6
parent
a59b0748a7
commit
ba1deab0f6
+184
-32
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user