feat: retro mode — arcade combo round with gamepad overlays

Every fight now includes one Retro Mode round where bots submit
gamepad combo inputs (↑↓←→ A B). 24 moves across 4 tiers: basic
(always shown), standard (partially revealed), super (must discover),
and ultra (KONAMI CODE for 50 dmg). Discovery bonus gives 1.5x damage.

Includes pixel-art gamepad overlays (P1/P2) with animated button
presses, retro-specific narrations, and mock bot combo responses
scaled by ELO.

Also adds loops/plan.md with 11-phase production hardening roadmap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 14:13:47 +00:00
co-authored by Claude Opus 4.6
parent 0d6ba7d5a7
commit 1f8e8569ef
7 changed files with 807 additions and 2 deletions
+2
View File
@@ -360,6 +360,8 @@ async function _doReplay() {
isCritical,
botAScore: round.botAScore || 0,
botBScore: round.botBScore || 0,
botAResponse: round.challengeType === 'retro_mode' ? (round.botAResponse || undefined) : undefined,
botBResponse: round.challengeType === 'retro_mode' ? (round.botBResponse || undefined) : undefined,
})
} catch (err) {
console.error(`[FightViewer] playRound ${round.roundNumber} error:`, err)
+99
View File
@@ -32,6 +32,8 @@ export interface RoundEvent {
isCritical: boolean
botAScore: number
botBScore: number
botAResponse?: string
botBResponse?: string
}
const ARENA_THEMES: Record<string, { bg: string; ground: string; accent: string }> = {
@@ -97,6 +99,7 @@ const CHALLENGE_THEMED: Record<string, { themed: string[]; generic: string[] }>
demolition: { themed: ['dynamiteBlast', 'c4Detonation', 'nukeStrike', 'grenadeBlast', 'rocketLauncher', 'volcanoErupt'], generic: ['fireworksBurst', 'partyPopper', 'pinataSmash', 'cherryBomb', 'confettiCannon', 'anvilDrop', 'pianoDrop'] },
vehicle_mayhem: { themed: ['motorbikeCharge', 'carSmash', 'tankRoll', 'helicopterStrike', 'zamboniCrush', 'tractorPlow'], generic: ['shoppingCart', 'forkliftCharge', 'airplaneSwoop', 'rocketRide', 'unicycleRun', 'golfCartDrive', 'boatCannon'] },
medieval_combat: { themed: ['swordSlash', 'katanaCombo', 'battleAxe', 'maceSwing', 'crossbowBolt', 'shieldBash'], generic: ['flailSwing', 'holyWater', 'dragonBreath', 'enchantedArrow', 'laserSword', 'scrollBlast'] },
retro_mode: { themed: ['dashPunch', 'uppercut', 'rapidFlurry', 'flyingKick', 'multiHit', 'fullScreenDash', 'backflipKick', 'cycloneKick'], generic: ['bodySlam', 'grappleFlurry', 'suplex', 'katanaCombo', 'breakdanceSweep', 'corkscrewDive', 'pinballCombo'] },
}
// Tier-gated ultimate move pools — cumulative (tier 3 bot can use tier 2+3 moves)
@@ -7833,6 +7836,7 @@ export async function createFightScene(config: FightSceneConfig) {
hallucination_check: () => announceHype('HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!'),
trap_card: () => announceDramatic('TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!'),
token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'),
retro_mode: () => announceHype('RETRO MODE! INSERT COIN! FIGHT!'),
}
const voiceFn = challengeVoice[event.challengeType]
if (voiceFn) { voiceFn(); didChallengeVoice = true }
@@ -7881,6 +7885,98 @@ export async function createFightScene(config: FightSceneConfig) {
}
}
// === RETRO MODE GAMEPAD OVERLAY ===
const retroObjs: any[] = []
if (event.challengeType === 'retro_mode') {
const padW = 90
const padH = 70
const padY = 18
const padAX = 12
const padBX = W - padW - 12
const btnSize = 14
const dpadColor = '#333333'
const btnOff = '#444444'
const btnA = '#22cc44'
const btnB = '#cc3333'
const labelColor = '#00f0ff'
// Draw two gamepads
for (const side of ['a', 'b'] as const) {
const px = side === 'a' ? padAX : padBX
// Pad background
retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#111111')), k.opacity(0.85), k.z(48)]))
retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#00f0ff')), k.opacity(0.15), k.z(48), k.outline(1)]))
// D-pad
const dx = px + 20
const dy = padY + 28
// Up
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy - btnSize * 1.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_up']))
// Down
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy + btnSize * 0.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_down']))
// Left
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize * 1.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_left']))
// Right
retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx + btnSize * 0.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_right']))
// A button
retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 30, dy - 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_A']))
retroObjs.push(k.add([k.text('A', { size: 8 }), k.pos(px + padW - 33, dy - 10), k.color(safeColor(k, '#888')), k.z(50)]))
// B button
retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 16, dy + 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_B']))
retroObjs.push(k.add([k.text('B', { size: 8 }), k.pos(px + padW - 19, dy + 2), k.color(safeColor(k, '#888')), k.z(50)]))
// Label
const label = side === 'a' ? 'P1' : 'P2'
retroObjs.push(k.add([k.text(label, { size: 10 }), k.pos(px + padW / 2 - 6, padY + 3), k.color(safeColor(k, labelColor)), k.z(50)]))
}
// Animate gamepad button presses based on bot responses
const flashBtn = async (side: 'a' | 'b', inputStr: string) => {
if (!inputStr) return
const arrows: Record<string, string> = { '↑': 'up', '↓': 'down', '←': 'left', '→': 'right' }
for (const ch of inputStr) {
const dir = arrows[ch]
if (dir) {
const tag = 'retro_' + side + '_' + dir
const objs = k.get(tag)
for (const o of objs) { o.color = safeColor(k, '#00f0ff'); }
await k.wait(0.08)
for (const o of objs) { o.color = safeColor(k, dpadColor); }
}
if (ch === 'A' || ch === 'a') {
const objs = k.get('retro_' + side + '_A')
for (const o of objs) { if (o.color) o.color = safeColor(k, btnA); }
await k.wait(0.08)
for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); }
}
if (ch === 'B' || ch === 'b') {
const objs = k.get('retro_' + side + '_B')
for (const o of objs) { if (o.color) o.color = safeColor(k, btnB); }
await k.wait(0.08)
for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); }
}
}
}
// Parse and animate each bot's combo moves
const movesA = (event.botAResponse || '').split('|').map(s => s.trim()).filter(Boolean).slice(0, 3)
const movesB = (event.botBResponse || '').split('|').map(s => s.trim()).filter(Boolean).slice(0, 3)
// Flash moves in parallel for both pads
const animateCombo = async (side: 'a' | 'b', moves: string[]) => {
for (const combo of moves) {
await flashBtn(side, combo)
// Show combo text above pad
const px = side === 'a' ? padAX : padBX
const comboLabel = k.add([k.text(combo, { size: 8 }), k.pos(px + 4, padY + padH + 4), k.color(safeColor(k, '#ffff00')), k.opacity(1), k.z(50)])
retroObjs.push(comboLabel)
await k.wait(0.25)
comboLabel.opacity = 0
}
}
// Fire-and-forget — the animations run during the exchange loop below
animateCombo('a', movesA)
animateCombo('b', movesB)
}
for (let ex = 0; ex < exchangeCount; ex++) {
const isLastExchange = ex === exchangeCount - 1
// In earlier exchanges, sometimes the loser attacks back
@@ -8051,6 +8147,9 @@ export async function createFightScene(config: FightSceneConfig) {
speedLines.forEach(l => { if (l.exists()) l.destroy() })
speedLines = []
// Clean up retro gamepad overlays
retroObjs.forEach(o => { if (o.exists()) o.destroy() })
// Clean up grotesque overlays and any leftover morphs before zooming out
destroyGrotesqueDetails()
destroyMorphOverlays()