Define Fighter type alias using Kaplay's GameObj with SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, and ZComp. Replace 81 choreography function signatures from (atk: any, def: any, ...) to (atk: Fighter, def: Fighter, ...). Type loadSpriteWithTimeout opts as LoadSpriteOpt. Type lines/bands arrays as GameObj[]. Type spawnAfterimages and cameraZoom fighter params. Add as Fighter casts at k.get() call sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
11317 lines
528 KiB
TypeScript
11317 lines
528 KiB
TypeScript
import kaplay from 'kaplay'
|
|
import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp, LoadSpriteOpt } from 'kaplay'
|
|
|
|
type Fighter = GameObj<SpriteComp | PosComp | ScaleComp | AnchorComp | OpacityComp | ColorComp | RotateComp | ZComp>
|
|
import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, generateHumanFighterSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites'
|
|
import {
|
|
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
|
|
sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect,
|
|
sfxRoundStart, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly, sfxBoing,
|
|
sfxBonk, sfxZap, sfxSlideDown, sfxSlideUp, sfxZoomWhoosh, sfxRapidPunch,
|
|
sfxSplat, sfxWomp, sfxPowerUp, sfxCoin, sfxFail, sfxDrumRoll,
|
|
fanfareRound, fanfareFight, fanfareDevastating, fanfareCritical, fanfareCombo,
|
|
startMusic, stopMusic, announce,
|
|
announceRandom, announceDramatic, announceHype, announceSilly, announceCool,
|
|
announceRobot, announceScream, announceFast,
|
|
announceFinishHim, announceFatality, announceFlawlessVictory,
|
|
announceRandomHype, announceDeepIntro, announceRoundHype, announceCrowdReaction,
|
|
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 {
|
|
canvas: HTMLCanvasElement
|
|
botA: { name: string; seed: string; tier: number; archetype?: string; customization?: SpriteCustomization; wins?: number; losses?: number }
|
|
botB: { name: string; seed: string; tier: number; archetype?: string; customization?: SpriteCustomization; wins?: number; losses?: number }
|
|
arena: string
|
|
onReady?: () => void
|
|
}
|
|
|
|
export interface RoundEvent {
|
|
round: number
|
|
challengeType: string
|
|
winnerId: string | null
|
|
botAId: string
|
|
botBId: string
|
|
narration: string
|
|
isCritical: boolean
|
|
botAScore: number
|
|
botBScore: number
|
|
botAResponse?: string
|
|
botBResponse?: string
|
|
}
|
|
|
|
const ARENA_THEMES: Record<string, { bg: string; ground: string; accent: string }> = {
|
|
datacenter: { bg: '#0a0a1a', ground: '#1a1a3a', accent: '#00f0ff' },
|
|
stackoverflow_ruins: { bg: '#1a0f00', ground: '#2a1f10', accent: '#f48024' },
|
|
gpu_graveyard: { bg: '#0a0a0a', ground: '#1a1a1a', accent: '#76b900' },
|
|
prompt_dungeon: { bg: '#0f0a1a', ground: '#1f1a2a', accent: '#b83dff' },
|
|
silicon_valley_dojo: { bg: '#0a1a0a', ground: '#1a2a1a', accent: '#00ff41' },
|
|
paper_mill: { bg: '#1a1a10', ground: '#2a2a20', accent: '#f0e68c' },
|
|
localhost: { bg: '#000000', ground: '#111111', accent: '#00ff41' },
|
|
the_cloud: { bg: '#0a0f1a', ground: '#1a1f2a', accent: '#4488ff' },
|
|
hacker_news: { bg: '#1a0f00', ground: '#2a1f10', accent: '#ff6600' },
|
|
the_singularity: { bg: '#1a0020', ground: '#2a0030', accent: '#ff00ff' },
|
|
beach: { bg: '#0a2040', ground: '#c2a060', accent: '#ffcc00' },
|
|
desert: { bg: '#1a1000', ground: '#a08040', accent: '#ff8800' },
|
|
forest: { bg: '#051005', ground: '#1a3010', accent: '#44cc22' },
|
|
jungle: { bg: '#081808', ground: '#1a3818', accent: '#33ff66' },
|
|
outer_space: { bg: '#000008', ground: '#0a0a20', accent: '#8888ff' },
|
|
colosseum: { bg: '#1a0f08', ground: '#3a2a18', accent: '#d4a030' },
|
|
kitchen_stadium: { bg: '#1a0a0a', ground: '#2a1a10', accent: '#ff4400' },
|
|
haunted_mansion: { bg: '#0a0510', ground: '#1a1020', accent: '#8844cc' },
|
|
meme_dimension: { bg: '#1a0820', ground: '#2a1830', accent: '#ff44ff' },
|
|
space_station: { bg: '#000010', ground: '#0a0a28', accent: '#44aaff' },
|
|
junkyard: { bg: '#0f0f0a', ground: '#2a2a1a', accent: '#aa8833' },
|
|
concert_hall: { bg: '#10001a', ground: '#20102a', accent: '#ff22aa' },
|
|
savanna: { bg: '#1a1808', ground: '#8a7830', accent: '#dd9922' },
|
|
server_room: { bg: '#000a05', ground: '#0a1a10', accent: '#00ff88' },
|
|
sports_arena: { bg: '#0a0a1a', ground: '#1a2a4a', accent: '#ff3333' },
|
|
}
|
|
|
|
const spriteAnims = {
|
|
idle: { from: 0, to: ANIMATIONS.idle.frames - 1, loop: true, speed: 6 },
|
|
attack: { from: MAX_FRAMES, to: MAX_FRAMES + ANIMATIONS.attack.frames - 1, loop: false, speed: 12 },
|
|
kick: { from: MAX_FRAMES * 2, to: MAX_FRAMES * 2 + ANIMATIONS.kick.frames - 1, loop: false, speed: 10 },
|
|
special: { from: MAX_FRAMES * 3, to: MAX_FRAMES * 3 + ANIMATIONS.special.frames - 1, loop: false, speed: 8 },
|
|
hit: { from: MAX_FRAMES * 4, to: MAX_FRAMES * 4 + ANIMATIONS.hit.frames - 1, loop: false, speed: 8 },
|
|
knockback: { from: MAX_FRAMES * 5, to: MAX_FRAMES * 5 + ANIMATIONS.knockback.frames - 1, loop: false, speed: 8 },
|
|
ko: { from: MAX_FRAMES * 6, to: MAX_FRAMES * 6 + ANIMATIONS.ko.frames - 1, loop: false, speed: 6 },
|
|
win: { from: MAX_FRAMES * 7, to: MAX_FRAMES * 7 + ANIMATIONS.win.frames - 1, loop: true, speed: 6 },
|
|
}
|
|
|
|
// Challenge type -> themed choreography (shown ~60% of the time) + generic fallbacks
|
|
const CHALLENGE_THEMED: Record<string, { themed: string[]; generic: string[] }> = {
|
|
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'] },
|
|
hallucination_check: { themed: ['cloneStrike', 'portalPunch', 'shadowClone', 'glitchBeam', 'voidRift'], generic: ['teleportStrike', 'dashThrough', 'riddleBarrage', 'rapidFlurry', 'sniperShot', 'darkVoid', 'memeBeam'] },
|
|
token_economy: { themed: ['coinShower', 'bitcoinCrash', 'nftRugPull', 'vendingDrop', 'popupSpam'], generic: ['dashPunch', 'flyingKick', 'uppercut', 'zoomRush', 'bombThrow', 'minigunSpray', 'pocketCannon'] },
|
|
creative_writing: { themed: ['penStab', 'scrollBlast', 'bookStorm', 'enchantedArrow', 'newspaperRoll'], generic: ['jetpackDive', 'aerialSlam', 'projectile', 'rapidFlurry', 'katanaCombo', 'arcaneBarrage', 'memeBeam'] },
|
|
math_blitz: { themed: ['mathAttack', 'dataStream', 'pixelBeam', 'hackerAttack', 'chainLightning'], generic: ['multiHit', 'dashPunch', 'fullScreenDash', 'rapidFlurry', 'laserBeam', 'hammerSmash', 'pinballCombo'] },
|
|
trap_card: { themed: ['trapCardAttack', 'captchaTrap', 'spiderWeb', 'flashBang', 'smokeBomb'], generic: ['gunBurst', 'teleportStrike', 'groundPound', 'zoomRush', 'sniperShot', 'rocketLauncher', 'minigunSpray'] },
|
|
food_fight: { themed: ['pizzaSlam', 'bananaFling', 'pieSmash', 'hotdogWhip', 'watermelonBomb', 'sushiBarrage', 'burgerToss', 'tacoStorm', 'eggBombard', 'tomatoBarrage'], generic: ['popcornBlast', 'iceCreamFling', 'donutBarrage', 'cookieFling', 'nachoVolley', 'meatballStorm', 'candyBarrage'] },
|
|
wrestling_match: { themed: ['piledriver', 'powerbomb', 'chokeslam', 'tombstone', 'rko', 'clothesline', 'suplex', 'germanSuplex'], generic: ['bodySlam', 'grappleFlurry', 'hurricanrana', 'moonsault', 'elbowDrop', 'spear', 'stunner'] },
|
|
music_battle: { themed: ['bassDrop', 'guitarSolo', 'drumSolo', 'dubstepCannon', 'beatboxBlast', 'vinylScratch'], generic: ['airHornBlast', 'vuvuzelaBlast', 'sonicWave', 'bassDropBeam', 'karaokeAttack', 'airGuitar'] },
|
|
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', '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'] },
|
|
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)
|
|
const TIER_ULTIMATES: Record<number, string[]> = {
|
|
2: ['ultimateRapidBarrage', 'ultimateOrbitalStrike', 'ultimateCloneSurround', 'ultimateGatlingFury', 'ultimateEarthquake', 'ultimateBouncingFury', 'ultimateTripleBeam', 'ultimateMegaThrow', 'ultimateTornadoGrab', 'ultimateMeteorShower'],
|
|
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', 'bulletTimeAttack']
|
|
const CREATOR_ULTIMATES = ['ultimateGenesisBlock', 'ultimateTheHalving', 'ultimateFullNodeCrush', 'ultimateSatoshiVision']
|
|
|
|
function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string {
|
|
// THE CREATOR: special move selection — 50% ultimate, 60% creator moves, rest normal
|
|
if (attackerArchetype === 'the_creator') {
|
|
const ultimateChance = isCritical ? 1.0 : 0.5
|
|
if (Math.random() < ultimateChance) {
|
|
// Creator gets ALL ultimates + their 4 exclusive ones
|
|
const all: string[] = [...CREATOR_ULTIMATES]
|
|
for (let t = 2; t <= 5; t++) {
|
|
if (TIER_ULTIMATES[t]) all.push(...TIER_ULTIMATES[t])
|
|
}
|
|
return all[Math.floor(Math.random() * all.length)]
|
|
}
|
|
// 60% creator-themed, 40% normal selection
|
|
if (Math.random() < 0.6) {
|
|
return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)]
|
|
}
|
|
// Fall through to normal selection
|
|
}
|
|
|
|
// Tier-gated ultimates: higher tier = higher chance of spectacular ultimate moves
|
|
// Tier 2: 15% chance, Tier 3: 20%, Tier 4: 30%, Tier 5: 40%
|
|
if (attackerTier >= 2) {
|
|
const ultimateChance = attackerTier === 2 ? 0.15 : attackerTier === 3 ? 0.20 : attackerTier === 4 ? 0.30 : 0.40
|
|
// On crits, double the chance
|
|
const roll = isCritical ? ultimateChance * 2 : ultimateChance
|
|
if (Math.random() < roll) {
|
|
// Collect all ultimates available at this tier (cumulative — higher tiers get lower tier moves too)
|
|
const available: string[] = []
|
|
for (let t = 2; t <= attackerTier; t++) {
|
|
if (TIER_ULTIMATES[t]) available.push(...TIER_ULTIMATES[t])
|
|
}
|
|
if (available.length > 0) {
|
|
return available[Math.floor(Math.random() * available.length)]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Critical hits always get BIG moves (but can still be themed)
|
|
if (isCritical) {
|
|
const entry = CHALLENGE_THEMED[challengeType]
|
|
// 50% themed, 50% epic generic for crits
|
|
if (entry && Math.random() < 0.5) {
|
|
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
|
|
}
|
|
const critMoves = [
|
|
'groundPound', 'jetpackDive', 'gunBurst', 'fullScreenDash', 'multiHit', 'fireBreath',
|
|
'lightningRush', 'zoomRush', 'rapidFlurry', 'rocketLauncher', 'laserBeam', 'katanaCombo',
|
|
'hammerSmash', 'sniperShot', 'minigunSpray', 'chainsawRev', 'carSmash', 'motorbikeCharge',
|
|
'anvilDrop', 'pocketCannon', 'bodySlam', 'suplex', 'pinballCombo', 'grappleFlurry',
|
|
// New epic crits
|
|
'kamehameha', 'nukeStrike', 'tankRoll', 'helicopterStrike', 'chokeslam', 'tombstone',
|
|
'rko', 'piledriver', 'powerbomb', 'meteorStrike', 'spiritBomb', 'blackHole',
|
|
'alienAbduction', 'tsunamiWave', 'earthquakeStrike', 'dragonBreath', 'pianoDrop',
|
|
'fridgeDrop', 'volcanoErupt', 'warpDrive', 'dubstepCannon', 'tornadoSpin',
|
|
'trampolineBounce', 'watermelonBomb', 'nftRugPull', 'blueScreen', 'yeetThrow',
|
|
'bulletTimeAttack',
|
|
]
|
|
return critMoves[Math.floor(Math.random() * critMoves.length)]
|
|
}
|
|
|
|
const entry = CHALLENGE_THEMED[challengeType]
|
|
if (!entry) {
|
|
const fallback = ['dashPunch', 'flyingKick', 'aerialSlam', 'backflipKick', 'fingerGuns', 'rubberChicken', 'pizzaSlam', 'fryingPan', 'snowballFight']
|
|
return fallback[Math.floor(Math.random() * fallback.length)]
|
|
}
|
|
|
|
// 60% themed, 25% generic fallback, 15% wild card
|
|
const roll = Math.random()
|
|
if (roll < 0.6) {
|
|
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
|
|
} else if (roll < 0.85) {
|
|
return entry.generic[Math.floor(Math.random() * entry.generic.length)]
|
|
} else {
|
|
const wild = [
|
|
'jetpackDive', 'gunBurst', 'groundPound', 'teleportStrike', 'fullScreenDash', 'fireBreath',
|
|
'golfClubSmash', 'zoomRush', 'rapidFlurry', 'swordSlash', 'hammerSmash', 'laserBeam',
|
|
'rocketLauncher', 'bombThrow', 'minigunSpray', 'sniperShot', 'whipCrack', 'katanaCombo',
|
|
'chainsawRev', 'motorbikeCharge', 'carSmash', 'boatCannon', 'anvilDrop', 'pocketCannon',
|
|
'grappleFlurry', 'bodySlam', 'pinballCombo', 'suplex',
|
|
// New wild cards — food
|
|
'pizzaSlam', 'bananaFling', 'pieSmash', 'burgerToss', 'tomatoBarrage', 'fishSlap',
|
|
// New wild cards — weapons
|
|
'fryingPan', 'baseballBat', 'guitarSmash', 'umbrellaWhack', 'plungerSlam', 'wrenchSmash',
|
|
// New wild cards — drops
|
|
'pianoDrop', 'toiletDrop', 'tvDrop', 'chandelierDrop', 'micDrop',
|
|
// New wild cards — vehicles
|
|
'shoppingCart', 'zamboniCrush', 'unicycleRun', 'tankRoll',
|
|
// New wild cards — spins/flips
|
|
'tornadoSpin', 'backflipKick', 'corkscrewDive', 'barrelRoll', 'skateTrick',
|
|
// New wild cards — beams
|
|
'kamehameha', 'freezeRay', 'heatVision', 'rainbowBeam', 'glitchBeam',
|
|
// New wild cards — silly
|
|
'rubberChicken', 'selfieStrike', 'fingerGuns', 'yeetThrow', 'emojiBarrage', 'touchGrass',
|
|
// New wild cards — wrestling
|
|
'clothesline', 'rko', 'stunner', 'piledriver',
|
|
// New wild cards — magic
|
|
'fireball', 'portalPunch', 'crystalShards', 'spiritBomb',
|
|
// New wild cards — tech
|
|
'stackOverflow', 'blueScreen', 'aiUprising', 'popupSpam',
|
|
// New wild cards — music
|
|
'bassDrop', 'dubstepCannon', 'vinylScratch',
|
|
// New wild cards — nature
|
|
'avalanche', 'lavaSplash', 'tornadoFling',
|
|
]
|
|
return wild[Math.floor(Math.random() * wild.length)]
|
|
}
|
|
}
|
|
|
|
// Convert any CSS color string to a kaplay Color object safely
|
|
function safeColor(k: ReturnType<typeof kaplay>, color: string) {
|
|
if (color.startsWith('#')) {
|
|
try { return k.Color.fromHex(color) } catch { /* fall through */ }
|
|
}
|
|
// HSL / rgb / named color — render to a 1px canvas to extract RGB
|
|
const cv = document.createElement('canvas')
|
|
cv.width = 1; cv.height = 1
|
|
const cx = cv.getContext('2d')!
|
|
cx.fillStyle = color
|
|
cx.fillRect(0, 0, 1, 1)
|
|
const [r, g, b] = cx.getImageData(0, 0, 1, 1).data
|
|
return k.Color.fromArray([r, g, b])
|
|
}
|
|
|
|
export async function createFightScene(config: FightSceneConfig) {
|
|
const { canvas, botA, botB, arena } = config
|
|
const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost
|
|
|
|
const k = kaplay({
|
|
canvas,
|
|
width: canvas.width || 800,
|
|
height: canvas.height || 500,
|
|
background: theme.bg,
|
|
global: false,
|
|
scale: 1,
|
|
crisp: true,
|
|
texFilter: 'nearest',
|
|
})
|
|
|
|
const colorsA = getBotColors(botA.seed)
|
|
const colorsB = getBotColors(botB.seed)
|
|
|
|
let sheetA: string
|
|
let sheetB: string
|
|
|
|
// Human fighters use their profile human character, not a bot sprite
|
|
const winRateA = (botA.wins || 0) / Math.max(1, (botA.wins || 0) + (botA.losses || 0))
|
|
const winRateB = (botB.wins || 0) / Math.max(1, (botB.wins || 0) + (botB.losses || 0))
|
|
|
|
try {
|
|
sheetA = botA.archetype === 'human'
|
|
? generateHumanFighterSpriteSheet(botA.seed, botA.archetype, colorsA.primary, colorsA.secondary, winRateA)
|
|
: generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization)
|
|
} catch (err) {
|
|
console.error('[FightScene] Failed to generate sprite for botA:', err)
|
|
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
|
}
|
|
try {
|
|
sheetB = botB.archetype === 'human'
|
|
? generateHumanFighterSpriteSheet(botB.seed, botB.archetype, colorsB.primary, colorsB.secondary, winRateB)
|
|
: generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization)
|
|
} catch (err) {
|
|
console.error('[FightScene] Failed to generate sprite for botB:', err)
|
|
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
|
}
|
|
|
|
const isHumanA = botA.archetype === 'human'
|
|
const isHumanB = botB.archetype === 'human'
|
|
|
|
// Helper: load sprite with a 5s timeout so mobile never hangs forever
|
|
async function loadSpriteWithTimeout(name: string, src: string, opts: LoadSpriteOpt): Promise<void> {
|
|
try {
|
|
await Promise.race([
|
|
k.loadSprite(name, src, opts),
|
|
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
|
|
])
|
|
} catch (err) {
|
|
console.warn(`[FightScene] Sprite '${name}' failed/timed out:`, err)
|
|
}
|
|
}
|
|
|
|
// Await sprite loading — timeout prevents mobile hangs
|
|
await Promise.all([
|
|
loadSpriteWithTimeout('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
|
loadSpriteWithTimeout('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
|
])
|
|
|
|
// Preload bot sprites for human fighters (used when humans morph into bots)
|
|
if (isHumanA) {
|
|
const botSheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
|
loadSpriteWithTimeout('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
|
}
|
|
if (isHumanB) {
|
|
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
|
loadSpriteWithTimeout('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
|
}
|
|
|
|
// Judge sprite (red lobster referee)
|
|
const judgeSheet = generateJudgeSpriteSheet()
|
|
const judgeAnims = {
|
|
idle: { from: 0, to: JUDGE_ANIMATIONS.idle.frames - 1, loop: true, speed: 4 },
|
|
call_left: { from: JUDGE_MAX_FRAMES, to: JUDGE_MAX_FRAMES + JUDGE_ANIMATIONS.call_left.frames - 1, loop: false, speed: 8 },
|
|
call_right: { from: JUDGE_MAX_FRAMES * 2, to: JUDGE_MAX_FRAMES * 2 + JUDGE_ANIMATIONS.call_right.frames - 1, loop: false, speed: 8 },
|
|
shocked: { from: JUDGE_MAX_FRAMES * 3, to: JUDGE_MAX_FRAMES * 3 + JUDGE_ANIMATIONS.shocked.frames - 1, loop: false, speed: 10 },
|
|
}
|
|
await loadSpriteWithTimeout('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
|
|
|
|
// Human sprites (the bot owner's silly human avatar for crowd)
|
|
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
|
|
const humanSheetB = generateHumanSpriteSheet(botB.seed, botB.archetype || 'standard', colorsB.primary, colorsB.secondary, winRateB)
|
|
const humanAnims: Record<string, { from: number; to: number; loop: boolean; speed: number }> = {}
|
|
for (const [name, cfg] of Object.entries(HUMAN_ANIMATIONS)) {
|
|
humanAnims[name] = { from: cfg.row * HUMAN_MAX_FRAMES, to: cfg.row * HUMAN_MAX_FRAMES + cfg.frames - 1, loop: true, speed: 6 }
|
|
}
|
|
await Promise.all([
|
|
loadSpriteWithTimeout('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
|
loadSpriteWithTimeout('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
|
])
|
|
|
|
const W = k.width()
|
|
const H = k.height()
|
|
// Scale factor so sprites shrink on small canvases (reference: 500px tall)
|
|
const SF = Math.min(1, H / 500)
|
|
const GROUND_Y = H * 0.78
|
|
const HOME_A = W * 0.28
|
|
const HOME_B = W * 0.72
|
|
|
|
// Particle helpers
|
|
function spawnSparks(x: number, y: number, count: number, color: string) {
|
|
const intense = count > 8
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = Math.random() * Math.PI * 2
|
|
const speed = (intense ? 150 : 100) + Math.random() * (intense ? 400 : 300)
|
|
const size = (intense ? 3 : 2) + Math.random() * (intense ? 6 : 4)
|
|
const rotSpeed = (Math.random() - 0.5) * 600
|
|
const spark = k.add([
|
|
k.rect(size, size * (0.5 + Math.random() * 0.5)),
|
|
k.pos(x, y),
|
|
k.color(safeColor(k,color)),
|
|
k.opacity(1),
|
|
k.z(20),
|
|
k.rotate(Math.random() * 360),
|
|
k.anchor('center'),
|
|
{ vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 100, rotV: rotSpeed },
|
|
])
|
|
spark.onUpdate(() => {
|
|
spark.pos.x += (spark as any).vx * k.dt()
|
|
spark.pos.y += (spark as any).vy * k.dt()
|
|
;(spark as any).vy += 500 * k.dt() // gravity
|
|
spark.angle += (spark as any).rotV * k.dt()
|
|
spark.opacity -= 1.5 * k.dt()
|
|
if (spark.opacity <= 0) spark.destroy()
|
|
})
|
|
}
|
|
}
|
|
|
|
function spawnBulletHoles(x: number, y: number, count: number) {
|
|
for (let i = 0; i < count; i++) {
|
|
const hole = k.add([
|
|
k.circle(3 + Math.random() * 3),
|
|
k.pos(x + (Math.random() - 0.5) * 40, y + (Math.random() - 0.5) * 60),
|
|
k.color(safeColor(k,'#000000')),
|
|
k.opacity(0.8),
|
|
k.z(9), // behind fighters
|
|
])
|
|
// Fade out after a bit
|
|
setTimeout(() => {
|
|
const fadeInterval = setInterval(() => {
|
|
hole.opacity -= 0.02
|
|
if (hole.opacity <= 0) { hole.destroy(); clearInterval(fadeInterval) }
|
|
}, 50)
|
|
}, 1500)
|
|
}
|
|
}
|
|
|
|
function spawnExhaust(x: number, y: number, duration: number) {
|
|
const endTime = performance.now() + duration * 1000
|
|
const interval = setInterval(() => {
|
|
if (performance.now() > endTime) { clearInterval(interval); return }
|
|
for (let i = 0; i < 3; i++) {
|
|
const p = k.add([
|
|
k.circle(3 + Math.random() * 5),
|
|
k.pos(x + (Math.random() - 0.5) * 15, y),
|
|
k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')),
|
|
k.opacity(0.8),
|
|
k.z(8),
|
|
])
|
|
p.onUpdate(() => {
|
|
p.pos.y += 100 * k.dt()
|
|
p.pos.x += (Math.random() - 0.5) * 50 * k.dt()
|
|
p.opacity -= 2.5 * k.dt()
|
|
if (p.opacity <= 0) p.destroy()
|
|
})
|
|
}
|
|
}, 40)
|
|
return interval
|
|
}
|
|
|
|
function spawnProjectile(fromX: number, fromY: number, toX: number, toY: number, color: string, size: number = 8): Promise<void> {
|
|
return new Promise(resolve => {
|
|
const proj = k.add([
|
|
k.circle(size),
|
|
k.pos(fromX, fromY),
|
|
k.color(safeColor(k,color)),
|
|
k.opacity(1),
|
|
k.z(15),
|
|
])
|
|
// Trail
|
|
const trail = setInterval(() => {
|
|
const t = k.add([
|
|
k.circle(size * 0.6),
|
|
k.pos(proj.pos.x, proj.pos.y),
|
|
k.color(safeColor(k,color)),
|
|
k.opacity(0.5),
|
|
k.z(14),
|
|
])
|
|
t.onUpdate(() => { t.opacity -= 3 * k.dt(); if (t.opacity <= 0) t.destroy() })
|
|
}, 30)
|
|
|
|
const dx = toX - fromX
|
|
const dy = toY - fromY
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
const speed = 800
|
|
const dur = dist / speed
|
|
|
|
k.tween(0, 1, dur, (t) => {
|
|
proj.pos.x = fromX + dx * t
|
|
proj.pos.y = fromY + dy * t
|
|
}, k.easings.linear).then(() => {
|
|
clearInterval(trail)
|
|
proj.destroy()
|
|
spawnSparks(toX, toY, 8, color)
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
function spawnBullet(fromX: number, fromY: number, toX: number, toY: number): Promise<void> {
|
|
return new Promise(resolve => {
|
|
const bullet = k.add([
|
|
k.rect(6, 2),
|
|
k.pos(fromX, fromY),
|
|
k.color(safeColor(k,'#ffee00')),
|
|
k.opacity(1),
|
|
k.z(15),
|
|
k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI),
|
|
])
|
|
const dur = 0.08 + Math.random() * 0.04
|
|
k.tween(0, 1, dur, (t) => {
|
|
bullet.pos.x = fromX + (toX - fromX) * t
|
|
bullet.pos.y = fromY + (toY - fromY) * t + (Math.random() - 0.5) * 4
|
|
}, k.easings.linear).then(() => {
|
|
bullet.destroy()
|
|
// Muzzle flash at impact
|
|
spawnSparks(toX, toY, 3, '#ffcc00')
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
function spawnShockwave(x: number, y: number, color: string) {
|
|
const wave = k.add([
|
|
k.circle(5),
|
|
k.pos(x, y),
|
|
k.color(safeColor(k,color)),
|
|
k.opacity(0.7),
|
|
k.z(5),
|
|
k.scale(1),
|
|
])
|
|
k.tween(1, 15, 0.4, (v) => { wave.scaleTo(v, v * 0.3) }, k.easings.easeOutQuad)
|
|
k.tween(0.7, 0, 0.4, (v) => { wave.opacity = v }, k.easings.easeOutQuad).then(() => wave.destroy())
|
|
}
|
|
|
|
// === VISUAL CHAOS EFFECTS ===
|
|
|
|
// RGB split glitch — offsets red/blue channels using colored overlays
|
|
function glitchRGB(duration: number = 0.2) {
|
|
const layers = [
|
|
{ color: '#ff0000', dx: 4 + Math.random() * 6, dy: -2 },
|
|
{ color: '#0000ff', dx: -(4 + Math.random() * 6), dy: 2 },
|
|
]
|
|
for (const l of layers) {
|
|
const overlay = k.add([
|
|
k.rect(W, H), k.pos(l.dx, l.dy),
|
|
k.color(safeColor(k,l.color)), k.opacity(0.12), k.z(55),
|
|
])
|
|
overlay.onUpdate(() => {
|
|
overlay.pos.x = l.dx + (Math.random() - 0.5) * 4
|
|
overlay.pos.y = l.dy + (Math.random() - 0.5) * 2
|
|
})
|
|
setTimeout(() => { if (overlay.exists()) overlay.destroy() }, duration * 1000)
|
|
}
|
|
}
|
|
|
|
// Scanline glitch — horizontal bands flicker
|
|
function scanlineGlitch(duration: number = 0.3) {
|
|
const lines: GameObj[] = []
|
|
const count = 6 + Math.floor(Math.random() * 8)
|
|
for (let i = 0; i < count; i++) {
|
|
const ly = Math.random() * H
|
|
const lh = 1 + Math.random() * 3
|
|
const line = k.add([
|
|
k.rect(W, lh), k.pos(0, ly),
|
|
k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : '#000000')),
|
|
k.opacity(0.15 + Math.random() * 0.2), k.z(54),
|
|
])
|
|
line.onUpdate(() => {
|
|
line.pos.x = (Math.random() - 0.5) * 8
|
|
line.opacity = Math.random() > 0.3 ? 0.15 : 0
|
|
})
|
|
lines.push(line)
|
|
}
|
|
setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000)
|
|
}
|
|
|
|
// VHS tracking distortion — horizontal offset bands
|
|
function vhsTracking(duration: number = 0.4) {
|
|
const bands: GameObj[] = []
|
|
const bandCount = 3
|
|
for (let i = 0; i < bandCount; i++) {
|
|
const by = Math.random() * H
|
|
const bh = 10 + Math.random() * 30
|
|
const band = k.add([
|
|
k.rect(W, bh), k.pos(0, by),
|
|
k.color(safeColor(k,theme.accent)), k.opacity(0.06), k.z(53),
|
|
])
|
|
band.onUpdate(() => {
|
|
band.pos.x = Math.sin(k.time() * 20 + i * 3) * 15
|
|
band.pos.y += (Math.random() - 0.5) * 2
|
|
})
|
|
bands.push(band)
|
|
}
|
|
setTimeout(() => { bands.forEach(b => { if (b.exists()) b.destroy() }) }, duration * 1000)
|
|
}
|
|
|
|
// Dimensional shift — background color/hue flash cycle
|
|
function dimensionalShift(duration: number = 0.6) {
|
|
const colors = ['#ff00ff', '#00ffff', '#ff0000', '#0000ff', '#ffff00']
|
|
let idx = 0
|
|
const overlay = k.add([
|
|
k.rect(W, H), k.pos(0, 0),
|
|
k.color(safeColor(k,colors[0])), k.opacity(0.08), k.z(1),
|
|
])
|
|
const interval = setInterval(() => {
|
|
idx = (idx + 1) % colors.length
|
|
overlay.color = safeColor(k,colors[idx])
|
|
overlay.opacity = 0.05 + Math.random() * 0.06
|
|
}, 80)
|
|
setTimeout(() => { clearInterval(interval); if (overlay.exists()) overlay.destroy() }, duration * 1000)
|
|
}
|
|
|
|
// Schizo cut — rapid zoom/position jitter simulating jump cuts
|
|
async function schizoCut() {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
const origAX = fA.pos.x, origBX = fB.pos.x
|
|
const origAY = fA.pos.y, origBY = fB.pos.y
|
|
// 3-5 rapid cuts
|
|
const cuts = 3 + Math.floor(Math.random() * 3)
|
|
for (let i = 0; i < cuts; i++) {
|
|
// Random offset both fighters
|
|
fA.pos.x = origAX + (Math.random() - 0.5) * 30
|
|
fA.pos.y = origAY + (Math.random() - 0.5) * 15
|
|
fB.pos.x = origBX + (Math.random() - 0.5) * 30
|
|
fB.pos.y = origBY + (Math.random() - 0.5) * 15
|
|
screenFlash(Math.random() > 0.5 ? '#000000' : '#ffffff', 0.03)
|
|
await k.wait(0.04 + Math.random() * 0.03)
|
|
}
|
|
fA.pos.x = origAX; fA.pos.y = origAY
|
|
fB.pos.x = origBX; fB.pos.y = origBY
|
|
}
|
|
|
|
// Hyperspeed lines — converging toward a point
|
|
function hyperSpeedLines(targetX: number, targetY: number, duration: number = 0.3) {
|
|
const lines: GameObj[] = []
|
|
for (let i = 0; i < 16; i++) {
|
|
const angle = (i / 16) * Math.PI * 2
|
|
const dist = 300 + Math.random() * 200
|
|
const sx = targetX + Math.cos(angle) * dist
|
|
const sy = targetY + Math.sin(angle) * dist
|
|
const len = 30 + Math.random() * 60
|
|
const line = k.add([
|
|
k.rect(len, 1.5), k.pos(sx, sy),
|
|
k.color(safeColor(k,'#ffffff')), k.opacity(0.3), k.z(52),
|
|
k.rotate(angle * 180 / Math.PI + 180),
|
|
])
|
|
line.onUpdate(() => {
|
|
const dx = targetX - line.pos.x
|
|
const dy = targetY - line.pos.y
|
|
const d = Math.sqrt(dx * dx + dy * dy)
|
|
if (d > 10) {
|
|
line.pos.x += (dx / d) * 600 * k.dt()
|
|
line.pos.y += (dy / d) * 600 * k.dt()
|
|
}
|
|
line.opacity -= 0.8 * k.dt()
|
|
if (line.opacity <= 0 && line.exists()) line.destroy()
|
|
})
|
|
lines.push(line)
|
|
}
|
|
setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000)
|
|
}
|
|
|
|
// Flash the whole screen
|
|
function screenFlash(color: string, duration: number = 0.1) {
|
|
const flash = k.add([
|
|
k.rect(W, H),
|
|
k.pos(0, 0),
|
|
k.color(safeColor(k,color)),
|
|
k.opacity(0.4),
|
|
k.z(50),
|
|
])
|
|
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
|
|
}
|
|
|
|
// Impact freeze-frame: brief pause with white flash on devastating crits
|
|
async function impactFreeze(duration: number = 0.08) {
|
|
const overlay = k.add([
|
|
k.rect(W, H), k.pos(0, 0),
|
|
k.color(safeColor(k, '#ffffff')), k.opacity(0.5), k.z(55),
|
|
])
|
|
// Kaplay has no built-in pause, so we just hold with a wait + overlay
|
|
await k.wait(duration)
|
|
k.tween(0.5, 0, 0.06, (v) => { overlay.opacity = v }).then(() => overlay.destroy())
|
|
}
|
|
|
|
// Spawn afterimages: trailing ghost sprites behind a moving fighter
|
|
function spawnAfterimages(fighter: Fighter, count: number = 3) {
|
|
if (!fighter?.exists()) return
|
|
for (let i = 0; i < count; i++) {
|
|
const ghost = k.add([
|
|
k.rect(FRAME_SIZE * Math.abs(fighter.scale.x), FRAME_SIZE * Math.abs(fighter.scale.y)),
|
|
k.pos(fighter.pos.x - i * 8 * Math.sign(fighter.scale.x), fighter.pos.y),
|
|
k.color(safeColor(k, theme.accent)),
|
|
k.opacity(0.3 - i * 0.08),
|
|
k.z(fighter.z - 1),
|
|
k.anchor('bot'),
|
|
])
|
|
k.tween(ghost.opacity, 0, 0.15 + i * 0.05, (v) => { ghost.opacity = v }).then(() => ghost.destroy())
|
|
}
|
|
}
|
|
|
|
// Camera zoom: scale fighters toward/away from a focal point for impact emphasis
|
|
async function cameraZoom(
|
|
fighters: Fighter[],
|
|
savedScales: { x: number; y: number }[],
|
|
zoomFactor: number,
|
|
duration: number,
|
|
easing: (t: number) => number = k.easings.easeOutQuad,
|
|
) {
|
|
await Promise.all(fighters.map((f, i) => {
|
|
if (!f?.exists()) return Promise.resolve()
|
|
const sx = savedScales[i].x
|
|
const sy = savedScales[i].y
|
|
return k.tween(Math.abs(f.scale.y), Math.abs(sy) * zoomFactor, duration, (v) => {
|
|
f.scale.x = sx > 0 ? v : -v
|
|
f.scale.y = v
|
|
}, easing)
|
|
}))
|
|
}
|
|
|
|
// Grotesque close-up overlays removed — noops retained for call-site compatibility
|
|
function spawnGrotesqueDetails(_fighter: Fighter, _scaleFactor: number) {}
|
|
function destroyGrotesqueDetails() {}
|
|
// Arena-specific background decoration
|
|
function drawArenaDecor() {
|
|
const a = arena
|
|
const acc = theme.accent
|
|
|
|
// Parallax stars/particles in the sky
|
|
for (let i = 0; i < 40; i++) {
|
|
const star = k.add([
|
|
k.rect(1 + Math.random() * 2, 1 + Math.random() * 2),
|
|
k.pos(Math.random() * W, Math.random() * (GROUND_Y - 20)),
|
|
k.color(safeColor(k, i % 5 === 0 ? '#ffffff' : acc)),
|
|
k.opacity(0.08 + Math.random() * 0.2),
|
|
k.z(1),
|
|
])
|
|
const speed = 0.5 + Math.random() * 1.5
|
|
const baseOp = star.opacity
|
|
star.onUpdate(() => {
|
|
star.opacity = baseOp + Math.sin(k.time() * speed + i) * 0.08
|
|
})
|
|
}
|
|
|
|
if (a === 'datacenter' || a === 'localhost') {
|
|
// City skyline silhouette behind the racks
|
|
const buildingHeights = [0.55, 0.4, 0.65, 0.35, 0.7, 0.45, 0.6, 0.5, 0.72, 0.38, 0.58, 0.42]
|
|
for (let i = 0; i < buildingHeights.length; i++) {
|
|
const bw = W / buildingHeights.length + 4
|
|
const bh = GROUND_Y * buildingHeights[i]
|
|
const bx = i * (W / buildingHeights.length)
|
|
k.add([k.rect(bw, bh), k.pos(bx, GROUND_Y - bh), k.color(safeColor(k, '#060610')), k.opacity(0.5), k.z(0)])
|
|
for (let wy = 0; wy < bh - 10; wy += 8) {
|
|
for (let wx = 4; wx < bw - 6; wx += 7) {
|
|
const win = k.add([
|
|
k.rect(3, 3), k.pos(bx + wx, GROUND_Y - bh + 6 + wy),
|
|
k.color(safeColor(k, Math.random() > 0.4 ? '#223355' : '#ffcc44')),
|
|
k.opacity(0.15 + Math.random() * 0.2), k.z(0),
|
|
])
|
|
if (Math.random() < 0.15) {
|
|
const blink = 0.03 + Math.random() * 0.06
|
|
win.onUpdate(() => { win.opacity = Math.random() > blink ? win.opacity : (Math.random() > 0.5 ? 0.3 : 0.05) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Server rack wall
|
|
for (let row = 0; row < 4; row++) {
|
|
for (let col = 0; col < 10; col++) {
|
|
const lx = 15 + col * (W / 10)
|
|
const ly = 15 + row * 55
|
|
const rackW = W / 10 - 8
|
|
const rackH = 48
|
|
k.add([k.rect(rackW, rackH), k.pos(lx, ly), k.color(safeColor(k, '#06060f')), k.opacity(0.6), k.z(1)])
|
|
k.add([k.rect(rackW, 1), k.pos(lx, ly), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(rackW, 1), k.pos(lx, ly + rackH), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(1, rackH), k.pos(lx, ly), k.color(safeColor(k, '#1a1a3a')), k.opacity(0.3), k.z(1)])
|
|
for (let s = 0; s < 5; s++) {
|
|
k.add([k.rect(rackW - 6, 1), k.pos(lx + 3, ly + 8 + s * 8), k.color(safeColor(k, '#111122')), k.opacity(0.5), k.z(1)])
|
|
}
|
|
for (let led = 0; led < 3; led++) {
|
|
const ledEl = k.add([
|
|
k.rect(2, 2), k.pos(lx + 4 + led * 6, ly + 4),
|
|
k.color(safeColor(k, Math.random() > 0.3 ? '#00ff41' : '#ff2d2d')),
|
|
k.opacity(0.7), k.z(2),
|
|
])
|
|
const blinkRate = 0.02 + Math.random() * 0.08
|
|
ledEl.onUpdate(() => { ledEl.opacity = Math.random() > blinkRate ? 0.7 : 0.15 })
|
|
}
|
|
if (Math.random() < 0.4) {
|
|
const actLed = k.add([
|
|
k.rect(2, 2), k.pos(lx + rackW - 8, ly + 4),
|
|
k.color(safeColor(k, '#ffaa00')), k.opacity(0.6), k.z(2),
|
|
])
|
|
actLed.onUpdate(() => { actLed.opacity = Math.random() > 0.5 ? 0.7 : 0.2 })
|
|
}
|
|
}
|
|
}
|
|
// Cable bundles
|
|
for (let c = 0; c < 6; c++) {
|
|
const cy = 3 + c * 2
|
|
k.add([k.rect(W, 2), k.pos(0, cy), k.color(safeColor(k, c % 3 === 0 ? '#111133' : c % 3 === 1 ? '#0a0a22' : '#0f0f2a')), k.opacity(0.5), k.z(1)])
|
|
}
|
|
// Scan line sweep
|
|
const scanLine = k.add([k.rect(W, 1), k.pos(0, 0), k.color(safeColor(k, '#00f0ff')), k.opacity(0.06), k.z(3)])
|
|
scanLine.onUpdate(() => { scanLine.pos.y = (scanLine.pos.y + 40 * k.dt()) % GROUND_Y })
|
|
|
|
} else if (a === 'gpu_graveyard') {
|
|
// Floating circuit board fragments
|
|
for (let i = 0; i < 16; i++) {
|
|
const cx = Math.random() * W
|
|
const cy = 20 + Math.random() * (GROUND_Y - 60)
|
|
const cw = 15 + Math.random() * 25
|
|
const ch = 10 + Math.random() * 18
|
|
const chip = k.add([
|
|
k.rect(cw, ch), k.pos(cx, cy),
|
|
k.color(safeColor(k, Math.random() > 0.5 ? '#1a2a1a' : '#0f1a0f')),
|
|
k.opacity(0.35), k.z(1), k.rotate(Math.random() * 45 - 22),
|
|
])
|
|
for (let t = 0; t < 3; t++) {
|
|
k.add([k.rect(cw * 0.6, 1), k.pos(cx + 2, cy + 3 + t * 4), k.color(safeColor(k, '#76b900')), k.opacity(0.2), k.z(1)])
|
|
}
|
|
k.add([k.circle(1.5), k.pos(cx + cw / 2, cy + ch / 2), k.color(safeColor(k, '#aabb44')), k.opacity(0.25), k.z(1)])
|
|
const baseY = cy
|
|
chip.onUpdate(() => { chip.pos.y = baseY + Math.sin(k.time() * 0.3 + i) * 5 })
|
|
}
|
|
// Dead GPU fans — some slowly spinning
|
|
for (const fx of [W * 0.15, W * 0.5, W * 0.85]) {
|
|
const fy = GROUND_Y - 30 - Math.random() * 40
|
|
k.add([k.circle(18), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#0a0a0a')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.circle(5), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#1a1a1a')), k.opacity(0.3), k.z(1)])
|
|
for (let b = 0; b < 4; b++) {
|
|
const ba = b * Math.PI / 2
|
|
const blade = k.add([k.rect(14, 2), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, '#222222')), k.opacity(0.3), k.z(1), k.rotate(ba * 180 / Math.PI)])
|
|
if (fx === W * 0.5) {
|
|
blade.onUpdate(() => { blade.angle += 8 * k.dt() })
|
|
}
|
|
}
|
|
}
|
|
// Sparking wires
|
|
for (let i = 0; i < 4; i++) {
|
|
const sx = W * (0.2 + i * 0.2)
|
|
const sy = 10 + Math.random() * 30
|
|
k.add([k.rect(1, 20 + Math.random() * 40), k.pos(sx, sy), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(1)])
|
|
const spark = k.add([k.circle(3), k.pos(sx, sy + 20), k.anchor('center'), k.color(safeColor(k, '#ffff00')), k.opacity(0), k.z(3)])
|
|
spark.onUpdate(() => { spark.opacity = Math.random() > 0.97 ? 0.6 : 0 })
|
|
}
|
|
// Smoke wisps rising
|
|
for (let i = 0; i < 6; i++) {
|
|
const smoke = k.add([
|
|
k.circle(8 + Math.random() * 12), k.pos(Math.random() * W, GROUND_Y - 5),
|
|
k.color(safeColor(k, '#1a1a1a')), k.opacity(0.15), k.z(2),
|
|
])
|
|
const bx = smoke.pos.x
|
|
smoke.onUpdate(() => {
|
|
smoke.pos.y -= 6 * k.dt()
|
|
smoke.pos.x = bx + Math.sin(k.time() + i) * 8
|
|
smoke.opacity *= 0.998
|
|
if (smoke.pos.y < 0) { smoke.pos.y = GROUND_Y - 5; smoke.opacity = 0.15 }
|
|
})
|
|
}
|
|
|
|
} else if (a === 'prompt_dungeon') {
|
|
// Stone wall bricks
|
|
for (let row = 0; row < 5; row++) {
|
|
for (let col = 0; col < 12; col++) {
|
|
const brickW = W / 12 + (Math.random() - 0.5) * 10
|
|
const brickH = GROUND_Y / 5
|
|
const bx = col * (W / 12) + (row % 2 === 0 ? 0 : W / 24)
|
|
k.add([k.rect(brickW - 2, brickH - 2), k.pos(bx, row * brickH), k.color(safeColor(k, '#1a1520')), k.opacity(0.3), k.z(1)])
|
|
}
|
|
}
|
|
// Glowing runes with halos
|
|
for (let i = 0; i < 10; i++) {
|
|
const rx = 30 + Math.random() * (W - 60)
|
|
const ry = 20 + Math.random() * (GROUND_Y - 60)
|
|
const rune = k.add([
|
|
k.circle(6 + Math.random() * 8), k.pos(rx, ry),
|
|
k.color(safeColor(k, '#b83dff')), k.opacity(0.12), k.z(1),
|
|
])
|
|
k.add([k.circle(12 + Math.random() * 8), k.pos(rx, ry), k.color(safeColor(k, '#6622aa')), k.opacity(0.06), k.z(0)])
|
|
rune.onUpdate(() => { rune.opacity = 0.08 + Math.sin(k.time() * 0.8 + i * 0.7) * 0.08 })
|
|
}
|
|
// Torches with flickering flames and light cones
|
|
for (const tx of [W * 0.1, W * 0.35, W * 0.65, W * 0.9]) {
|
|
const ty = GROUND_Y * 0.3
|
|
k.add([k.rect(4, 20), k.pos(tx, ty), k.color(safeColor(k, '#553311')), k.opacity(0.5), k.z(1)])
|
|
const flame = k.add([k.circle(6), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff6600')), k.opacity(0.5), k.z(2)])
|
|
const flameGlow = k.add([k.circle(15), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff4400')), k.opacity(0.08), k.z(1)])
|
|
const lightCone = k.add([k.rect(30, GROUND_Y * 0.5), k.pos(tx - 13, ty + 20), k.color(safeColor(k, '#ff6600')), k.opacity(0.02), k.z(0)])
|
|
flame.onUpdate(() => {
|
|
flame.opacity = 0.4 + Math.sin(k.time() * 6 + tx) * 0.15
|
|
flame.pos.y = ty - 3 + Math.sin(k.time() * 8 + tx) * 1.5
|
|
flameGlow.opacity = 0.05 + Math.sin(k.time() * 6 + tx) * 0.04
|
|
lightCone.opacity = 0.015 + Math.sin(k.time() * 6 + tx) * 0.008
|
|
})
|
|
}
|
|
// Chains hanging from ceiling
|
|
for (let i = 0; i < 5; i++) {
|
|
const cx = W * (0.15 + i * 0.18)
|
|
const chainLen = 25 + Math.random() * 50
|
|
for (let s = 0; s < chainLen; s += 5) {
|
|
const link = k.add([k.rect(3, 4), k.pos(cx, s), k.color(safeColor(k, '#444444')), k.opacity(0.25), k.z(1)])
|
|
const baseCx = cx
|
|
link.onUpdate(() => { link.pos.x = baseCx + Math.sin(k.time() * 0.4 + i + s * 0.1) * 3 })
|
|
}
|
|
}
|
|
// Ground fog
|
|
for (let i = 0; i < 8; i++) {
|
|
const fog = k.add([
|
|
k.circle(35 + Math.random() * 30), k.pos(Math.random() * W, GROUND_Y - 8),
|
|
k.color(safeColor(k, '#1f1a2a')), k.opacity(0.25), k.z(3),
|
|
])
|
|
const baseX = fog.pos.x
|
|
fog.onUpdate(() => { fog.pos.x = baseX + Math.sin(k.time() * 0.2 + i) * 18 })
|
|
}
|
|
// Dripping water
|
|
for (let i = 0; i < 3; i++) {
|
|
const dx = W * (0.25 + i * 0.25)
|
|
const drop = k.add([k.circle(1.5), k.pos(dx, 0), k.color(safeColor(k, '#4466aa')), k.opacity(0.3), k.z(2)])
|
|
drop.onUpdate(() => {
|
|
drop.pos.y += 50 * k.dt()
|
|
if (drop.pos.y > GROUND_Y) { drop.pos.y = 0; drop.opacity = 0.3 }
|
|
drop.opacity *= 0.997
|
|
})
|
|
}
|
|
|
|
} else if (a === 'the_cloud') {
|
|
// Layered cloud formations
|
|
for (let layer = 0; layer < 3; layer++) {
|
|
for (let i = 0; i < 4; i++) {
|
|
const cx = Math.random() * W
|
|
const cy = 20 + layer * 50 + Math.random() * 30
|
|
const cloudOp = 0.25 - layer * 0.05
|
|
for (let j = 0; j < 4 + Math.floor(Math.random() * 3); j++) {
|
|
const cloud = k.add([
|
|
k.circle(15 + Math.random() * 20),
|
|
k.pos(cx + j * 14 - 20, cy + (Math.random() - 0.5) * 12),
|
|
k.color(safeColor(k, layer === 0 ? '#1a2030' : '#151a28')),
|
|
k.opacity(cloudOp), k.z(1),
|
|
])
|
|
const bx = cloud.pos.x
|
|
cloud.onUpdate(() => { cloud.pos.x = bx + Math.sin(k.time() * (0.08 + layer * 0.04) + i) * (8 + layer * 4) })
|
|
}
|
|
}
|
|
}
|
|
// Data rain streams
|
|
for (let i = 0; i < 15; i++) {
|
|
const stream = k.add([
|
|
k.rect(1, 8 + Math.random() * 15), k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k, '#4488ff')), k.opacity(0.12), k.z(2),
|
|
])
|
|
stream.onUpdate(() => {
|
|
stream.pos.y += 30 * k.dt()
|
|
if (stream.pos.y > GROUND_Y) { stream.pos.y = -20; stream.pos.x = Math.random() * W }
|
|
})
|
|
}
|
|
// Lightning flashes
|
|
const lightning = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(3)])
|
|
lightning.onUpdate(() => { lightning.opacity = Math.random() > 0.998 ? 0.06 : 0 })
|
|
// Rainbow arc (subtle)
|
|
const rainbowColors = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff']
|
|
for (let i = 0; i < rainbowColors.length; i++) {
|
|
k.add([
|
|
k.circle(120 + i * 6), k.pos(W * 0.3, GROUND_Y + 60), k.anchor('center'),
|
|
k.color(safeColor(k, rainbowColors[i])), k.opacity(0.03), k.z(0),
|
|
])
|
|
}
|
|
|
|
} else if (a === 'the_singularity') {
|
|
// Central swirling vortex with multiple rings
|
|
for (let ring = 0; ring < 4; ring++) {
|
|
const ringDots = 12 + ring * 6
|
|
for (let i = 0; i < ringDots; i++) {
|
|
const angle = (i / ringDots) * Math.PI * 2
|
|
const baseDist = 20 + ring * 30
|
|
const dot = k.add([
|
|
k.circle(1.5 + ring * 0.5), k.pos(W / 2, GROUND_Y * 0.4),
|
|
k.color(safeColor(k, ring === 0 ? '#ffffff' : ring === 1 ? '#ff44ff' : ring === 2 ? '#ff00ff' : '#aa00aa')),
|
|
k.opacity(0.3 - ring * 0.05), k.z(1),
|
|
])
|
|
dot.onUpdate(() => {
|
|
const a2 = angle + k.time() * (0.8 - ring * 0.15)
|
|
const d2 = baseDist + Math.sin(k.time() * 0.8 + i) * 12
|
|
dot.pos.x = W / 2 + Math.cos(a2) * d2
|
|
dot.pos.y = GROUND_Y * 0.4 + Math.sin(a2) * d2 * 0.5
|
|
})
|
|
}
|
|
}
|
|
// Central core with pulsing glow
|
|
const core = k.add([k.circle(12), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.15), k.z(1)])
|
|
k.add([k.circle(30), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#ff00ff')), k.opacity(0.06), k.z(0)])
|
|
k.add([k.circle(60), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k, '#880088')), k.opacity(0.03), k.z(0)])
|
|
core.onUpdate(() => { core.opacity = 0.1 + Math.sin(k.time() * 2) * 0.08 })
|
|
// Energy tendrils reaching outward
|
|
for (let i = 0; i < 8; i++) {
|
|
const tendril = k.add([
|
|
k.rect(2, 40 + Math.random() * 60), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('top'),
|
|
k.color(safeColor(k, '#ff00ff')), k.opacity(0.06), k.z(0), k.rotate(i * 45),
|
|
])
|
|
tendril.onUpdate(() => { tendril.angle = i * 45 + k.time() * 15 })
|
|
}
|
|
// Warped space grid lines
|
|
for (let i = 0; i < 6; i++) {
|
|
const gridLine = k.add([
|
|
k.rect(W, 1), k.pos(0, GROUND_Y * (0.15 + i * 0.12)),
|
|
k.color(safeColor(k, '#440066')), k.opacity(0.08), k.z(0),
|
|
])
|
|
gridLine.onUpdate(() => { gridLine.pos.x = Math.sin(k.time() * 0.3 + i) * 10 - 5 })
|
|
}
|
|
|
|
} else if (a === 'silicon_valley_dojo') {
|
|
// Bamboo stalks with segments and leaves
|
|
for (const side of [0.04, 0.08, 0.12, 0.86, 0.91, 0.96]) {
|
|
const px = W * side
|
|
const segments = 5 + Math.floor(Math.random() * 3)
|
|
const segH = (GROUND_Y - 15) / segments
|
|
for (let s = 0; s < segments; s++) {
|
|
k.add([k.rect(5, segH - 2), k.pos(px, 15 + s * segH), k.color(safeColor(k, '#1a3a1a')), k.opacity(0.45), k.z(1)])
|
|
k.add([k.rect(7, 2), k.pos(px - 1, 15 + s * segH), k.color(safeColor(k, '#2a4a2a')), k.opacity(0.4), k.z(1)])
|
|
}
|
|
for (let l = 0; l < 3; l++) {
|
|
const ly = 20 + l * (GROUND_Y / 4)
|
|
const dir = Math.random() > 0.5 ? 1 : -1
|
|
const leaf = k.add([k.rect(18, 3), k.pos(px + dir * 4, ly), k.color(safeColor(k, '#22aa33')), k.opacity(0.2), k.z(1), k.rotate(dir * 25)])
|
|
leaf.onUpdate(() => { leaf.angle = dir * 25 + Math.sin(k.time() * 1.5 + l) * 3 })
|
|
k.add([k.rect(14, 2), k.pos(px + dir * 8, ly + 4), k.color(safeColor(k, '#1a8822')), k.opacity(0.15), k.z(1), k.rotate(dir * 35)])
|
|
}
|
|
}
|
|
// Zen garden raked lines
|
|
for (let i = 0; i < 8; i++) {
|
|
const gy = GROUND_Y + 5 + i * 8
|
|
k.add([k.rect(W * 0.6, 1), k.pos(W * 0.2, gy), k.color(safeColor(k, '#00ff41')), k.opacity(0.06), k.z(0)])
|
|
}
|
|
// Stone lanterns with warm glow
|
|
for (const lx of [W * 0.2, W * 0.8]) {
|
|
k.add([k.rect(10, 6), k.pos(lx, GROUND_Y - 20), k.color(safeColor(k, '#444444')), k.opacity(0.35), k.z(1)])
|
|
k.add([k.rect(6, 14), k.pos(lx + 2, GROUND_Y - 34), k.color(safeColor(k, '#555555')), k.opacity(0.3), k.z(1)])
|
|
k.add([k.rect(12, 3), k.pos(lx - 1, GROUND_Y - 37), k.color(safeColor(k, '#555555')), k.opacity(0.3), k.z(1)])
|
|
const glow = k.add([k.circle(8), k.pos(lx + 5, GROUND_Y - 27), k.anchor('center'), k.color(safeColor(k, '#ffaa33')), k.opacity(0.15), k.z(2)])
|
|
glow.onUpdate(() => { glow.opacity = 0.1 + Math.sin(k.time() * 2 + lx) * 0.06 })
|
|
}
|
|
// Cherry blossom petals falling
|
|
for (let i = 0; i < 12; i++) {
|
|
const petal = k.add([
|
|
k.circle(1.5 + Math.random()), k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k, Math.random() > 0.5 ? '#ffaacc' : '#ff88aa')), k.opacity(0.2), k.z(3),
|
|
])
|
|
const startX = petal.pos.x
|
|
petal.onUpdate(() => {
|
|
petal.pos.y += 8 * k.dt()
|
|
petal.pos.x = startX + Math.sin(k.time() * 1.2 + i) * 15
|
|
if (petal.pos.y > GROUND_Y) { petal.pos.y = -5 }
|
|
})
|
|
}
|
|
|
|
} else if (a === 'hacker_news' || a === 'stackoverflow_ruins') {
|
|
// Ruined building walls with cracks
|
|
for (let i = 0; i < 3; i++) {
|
|
const wallX = W * (0.15 + i * 0.3)
|
|
const wallH = GROUND_Y * (0.4 + Math.random() * 0.3)
|
|
k.add([k.rect(W * 0.2, wallH), k.pos(wallX, GROUND_Y - wallH), k.color(safeColor(k, '#1a1408')), k.opacity(0.3), k.z(0)])
|
|
for (let c = 0; c < 3; c++) {
|
|
k.add([k.rect(1, 10 + Math.random() * 15), k.pos(wallX + Math.random() * W * 0.18, GROUND_Y - wallH + Math.random() * wallH), k.color(safeColor(k, '#0a0800')), k.opacity(0.3), k.z(0), k.rotate(Math.random() * 30 - 15)])
|
|
}
|
|
}
|
|
// Code blocks floating
|
|
for (let i = 0; i < 14; i++) {
|
|
const bw = 25 + Math.random() * 60
|
|
const bh = 3 + Math.random() * 4
|
|
const bx = 15 + Math.random() * (W - 50)
|
|
const by = 15 + Math.random() * (GROUND_Y - 50)
|
|
k.add([k.rect(bw, bh), k.pos(bx, by), k.color(safeColor(k, acc)), k.opacity(0.06 + Math.random() * 0.06), k.z(1)])
|
|
if (Math.random() < 0.4) {
|
|
k.add([k.rect(2, bh), k.pos(bx - 4, by), k.color(safeColor(k, acc)), k.opacity(0.1), k.z(1)])
|
|
}
|
|
}
|
|
// Vote arrows
|
|
for (let i = 0; i < 5; i++) {
|
|
const ax = 20 + Math.random() * (W - 40)
|
|
const ay = 30 + Math.random() * (GROUND_Y - 70)
|
|
k.add([k.rect(4, 6), k.pos(ax, ay), k.color(safeColor(k, a === 'stackoverflow_ruins' ? '#f48024' : '#ff6600')), k.opacity(0.12), k.z(1)])
|
|
}
|
|
// Broken monitors with static
|
|
for (let i = 0; i < 3; i++) {
|
|
const mx = W * (0.2 + i * 0.3)
|
|
const my = GROUND_Y * 0.3 + Math.random() * 30
|
|
k.add([k.rect(22, 16), k.pos(mx, my), k.color(safeColor(k, '#0a0a0a')), k.opacity(0.5), k.z(1)])
|
|
k.add([k.rect(20, 14), k.pos(mx + 1, my + 1), k.color(safeColor(k, '#111111')), k.opacity(0.4), k.z(1)])
|
|
const staticEl = k.add([k.rect(18, 12), k.pos(mx + 2, my + 2), k.color(safeColor(k, '#888888')), k.opacity(0), k.z(2)])
|
|
staticEl.onUpdate(() => { staticEl.opacity = Math.random() > 0.92 ? 0.08 : 0 })
|
|
}
|
|
// Scrolling text fragments
|
|
for (let i = 0; i < 4; i++) {
|
|
const txt = k.add([
|
|
k.rect(30 + Math.random() * 40, 2), k.pos(Math.random() * W, 20 + i * (GROUND_Y / 5)),
|
|
k.color(safeColor(k, acc)), k.opacity(0.08), k.z(2),
|
|
])
|
|
txt.onUpdate(() => {
|
|
txt.pos.x -= 12 * k.dt()
|
|
if (txt.pos.x < -60) txt.pos.x = W + 20
|
|
})
|
|
}
|
|
|
|
} else if (a === 'beach') {
|
|
// Sun with rays and glow
|
|
const sunX = W * 0.8, sunY = 35
|
|
k.add([k.circle(45), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffdd44')), k.opacity(0.06), k.z(0)])
|
|
k.add([k.circle(30), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffdd44')), k.opacity(0.1), k.z(0)])
|
|
const sun = k.add([k.circle(18), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.5), k.z(1)])
|
|
sun.onUpdate(() => { sun.opacity = 0.4 + Math.sin(k.time() * 0.5) * 0.1 })
|
|
for (let r = 0; r < 8; r++) {
|
|
const angle = r * Math.PI / 4
|
|
const ray = k.add([k.rect(50, 2), k.pos(sunX, sunY), k.anchor('left'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.08), k.z(0), k.rotate(angle * 180 / Math.PI)])
|
|
ray.onUpdate(() => { ray.opacity = 0.05 + Math.sin(k.time() * 0.8 + r) * 0.04 })
|
|
}
|
|
// Ocean with layered waves
|
|
k.add([k.rect(W, 25), k.pos(0, GROUND_Y - 35), k.color(safeColor(k, '#1a4488')), k.opacity(0.2), k.z(1)])
|
|
for (let layer = 0; layer < 4; layer++) {
|
|
for (let i = 0; i < 5; i++) {
|
|
const wave = k.add([
|
|
k.rect(W * 0.3, 3 + layer), k.pos(Math.random() * W, GROUND_Y - 22 - layer * 6 - i * 2),
|
|
k.color(safeColor(k, layer < 2 ? '#3377bb' : '#2266aa')), k.opacity(0.18 - layer * 0.03), k.z(2),
|
|
])
|
|
const baseX = wave.pos.x
|
|
wave.onUpdate(() => { wave.pos.x = baseX + Math.sin(k.time() * (0.6 + layer * 0.2) + i * 1.5) * (15 + layer * 5) })
|
|
}
|
|
}
|
|
// Surf foam at shore
|
|
for (let i = 0; i < 6; i++) {
|
|
const foam = k.add([
|
|
k.rect(15 + Math.random() * 25, 2), k.pos(Math.random() * W, GROUND_Y - 6 + Math.random() * 4),
|
|
k.color(safeColor(k, '#ffffff')), k.opacity(0.12), k.z(3),
|
|
])
|
|
const baseX = foam.pos.x
|
|
foam.onUpdate(() => { foam.pos.x = baseX + Math.sin(k.time() * 0.8 + i) * 8; foam.opacity = 0.06 + Math.sin(k.time() + i) * 0.06 })
|
|
}
|
|
// Palm trees with segmented trunks, coconuts, swaying fronds
|
|
for (const px of [W * 0.05, W * 0.14, W * 0.88, W * 0.95]) {
|
|
const lean = px < W / 2 ? 8 : -8
|
|
const height = 8 + Math.floor(Math.random() * 3)
|
|
for (let s = 0; s < height; s++) {
|
|
const sy = GROUND_Y - 10 - s * 12
|
|
const segW = 7 - s * 0.4
|
|
k.add([k.rect(segW, 13), k.pos(px + s * lean / height, sy), k.color(safeColor(k, s % 2 === 0 ? '#3a2510' : '#2f1f0d')), k.opacity(0.5), k.z(1)])
|
|
k.add([k.rect(segW + 1, 1), k.pos(px + s * lean / height - 0.5, sy + 6), k.color(safeColor(k, '#4a3520')), k.opacity(0.2), k.z(1)])
|
|
}
|
|
const topX = px + lean, topY = GROUND_Y - 10 - height * 12
|
|
for (let c = 0; c < 2 + Math.floor(Math.random() * 2); c++) {
|
|
k.add([k.circle(2.5), k.pos(topX + (c - 1) * 4, topY + 8), k.anchor('center'), k.color(safeColor(k, '#553311')), k.opacity(0.4), k.z(1)])
|
|
}
|
|
for (let f = 0; f < 7; f++) {
|
|
const angle = -70 + f * 22
|
|
const frond = k.add([k.rect(35 + Math.random() * 10, 3), k.pos(topX, topY), k.anchor('left'), k.color(safeColor(k, f % 2 === 0 ? '#1a6600' : '#228800')), k.opacity(0.35), k.z(1), k.rotate(angle)])
|
|
frond.onUpdate(() => { frond.angle = angle + Math.sin(k.time() * 0.8 + f) * 3 })
|
|
}
|
|
}
|
|
// Seagulls drifting
|
|
for (let i = 0; i < 4; i++) {
|
|
const bx = W * 0.2 + Math.random() * W * 0.5
|
|
const by = 12 + Math.random() * 25
|
|
const wing1 = k.add([k.rect(6, 1), k.pos(bx, by), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1), k.rotate(-15)])
|
|
const wing2 = k.add([k.rect(6, 1), k.pos(bx + 5, by), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1), k.rotate(15)])
|
|
const startBx = bx
|
|
wing1.onUpdate(() => { wing1.pos.x = startBx + Math.sin(k.time() * 0.2 + i) * 30; wing2.pos.x = wing1.pos.x + 5 })
|
|
}
|
|
// Beach umbrella
|
|
const umbX = W * 0.35
|
|
k.add([k.rect(2, 30), k.pos(umbX, GROUND_Y - 30), k.color(safeColor(k, '#aa8844')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.circle(16), k.pos(umbX + 1, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k, '#ff4444')), k.opacity(0.2), k.z(1)])
|
|
k.add([k.circle(14), k.pos(umbX + 1, GROUND_Y - 31), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.08), k.z(1)])
|
|
|
|
} else if (a === 'desert') {
|
|
// Layered sand dunes
|
|
for (let d = 0; d < 5; d++) {
|
|
const dx = W * (0.1 + d * 0.2) + (Math.random() - 0.5) * 40
|
|
const dr = 50 + d * 15 + Math.random() * 20
|
|
k.add([k.circle(dr), k.pos(dx, GROUND_Y + dr * 0.6), k.anchor('center'), k.color(safeColor(k, '#aa8844')), k.opacity(0.15 + d * 0.03), k.z(1)])
|
|
}
|
|
// Road with dashed center line
|
|
k.add([k.rect(W * 0.25, GROUND_Y * 0.5), k.pos(W * 0.38, GROUND_Y * 0.3), k.color(safeColor(k, '#222222')), k.opacity(0.15), k.z(0)])
|
|
k.add([k.rect(2, GROUND_Y * 0.5), k.pos(W * 0.38, GROUND_Y * 0.3), k.color(safeColor(k, '#aaaa44')), k.opacity(0.08), k.z(0)])
|
|
k.add([k.rect(2, GROUND_Y * 0.5), k.pos(W * 0.63, GROUND_Y * 0.3), k.color(safeColor(k, '#aaaa44')), k.opacity(0.08), k.z(0)])
|
|
for (let d = 0; d < 8; d++) {
|
|
k.add([k.rect(2, 8), k.pos(W * 0.505, GROUND_Y * 0.32 + d * 16), k.color(safeColor(k, '#ffff44')), k.opacity(0.1), k.z(0)])
|
|
}
|
|
// Cacti with arms and flowers
|
|
for (const cx of [W * 0.08, W * 0.25, W * 0.75, W * 0.9]) {
|
|
const ch = 35 + Math.random() * 20
|
|
k.add([k.rect(7, ch), k.pos(cx, GROUND_Y - ch), k.color(safeColor(k, '#226622')), k.opacity(0.45), k.z(1)])
|
|
const armY = GROUND_Y - ch * 0.6
|
|
k.add([k.rect(12, 5), k.pos(cx - 12, armY), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(5, 15), k.pos(cx - 12, armY - 15), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
|
|
if (Math.random() > 0.3) {
|
|
const armY2 = GROUND_Y - ch * 0.4
|
|
k.add([k.rect(10, 5), k.pos(cx + 7, armY2), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(5, 12), k.pos(cx + 12, armY2 - 12), k.color(safeColor(k, '#226622')), k.opacity(0.4), k.z(1)])
|
|
}
|
|
if (Math.random() > 0.5) {
|
|
k.add([k.circle(2.5), k.pos(cx + 3, GROUND_Y - ch - 2), k.anchor('center'), k.color(safeColor(k, '#ff4488')), k.opacity(0.3), k.z(2)])
|
|
}
|
|
}
|
|
// Tumbleweeds rolling
|
|
for (let i = 0; i < 3; i++) {
|
|
const tumble = k.add([k.circle(6 + Math.random() * 4), k.pos(-20 - i * 100, GROUND_Y - 8 - Math.random() * 5), k.color(safeColor(k, '#886644')), k.opacity(0.3), k.z(2)])
|
|
const speed = 15 + Math.random() * 15
|
|
tumble.onUpdate(() => { tumble.pos.x = ((tumble.pos.x + speed * k.dt()) % (W + 60)) - 30 })
|
|
}
|
|
// Heat shimmer
|
|
for (let i = 0; i < 4; i++) {
|
|
const shimmer = k.add([k.rect(W, 1), k.pos(0, GROUND_Y - 3 - i * 3), k.color(safeColor(k, '#ff8800')), k.opacity(0.06), k.z(2)])
|
|
shimmer.onUpdate(() => { shimmer.opacity = 0.03 + Math.sin(k.time() * 3 + i * 2) * 0.03 })
|
|
}
|
|
// Vulture silhouettes circling
|
|
for (let i = 0; i < 2; i++) {
|
|
const vx = W * 0.4 + i * W * 0.2
|
|
const vy = 25 + i * 15
|
|
const v1 = k.add([k.rect(8, 1), k.pos(vx, vy), k.color(safeColor(k, '#222222')), k.opacity(0.25), k.z(1), k.rotate(-10)])
|
|
const v2 = k.add([k.rect(8, 1), k.pos(vx + 7, vy), k.color(safeColor(k, '#222222')), k.opacity(0.25), k.z(1), k.rotate(10)])
|
|
const startVx = vx
|
|
v1.onUpdate(() => {
|
|
const cx = startVx + Math.cos(k.time() * 0.15 + i) * 40
|
|
const cy = vy + Math.sin(k.time() * 0.15 + i) * 15
|
|
v1.pos.x = cx; v1.pos.y = cy; v2.pos.x = cx + 7; v2.pos.y = cy
|
|
})
|
|
}
|
|
|
|
} else if (a === 'forest') {
|
|
// Layered trees (3 depth layers)
|
|
for (let layer = 0; layer < 3; layer++) {
|
|
const treeCount = layer === 0 ? 14 : layer === 1 ? 8 : 5
|
|
const treeOp = layer === 0 ? 0.15 : layer === 1 ? 0.3 : 0.4
|
|
for (let i = 0; i < treeCount; i++) {
|
|
const tx = 10 + (i / treeCount) * (W - 20) + (Math.random() - 0.5) * 15
|
|
const th = (25 + Math.random() * 35) * (layer === 0 ? 0.6 : layer === 1 ? 0.85 : 1)
|
|
const trunkW = layer === 0 ? 4 : layer === 1 ? 6 : 8
|
|
k.add([k.rect(trunkW, th), k.pos(tx, GROUND_Y - th), k.color(safeColor(k, layer === 0 ? '#0f1a08' : layer === 1 ? '#152010' : '#1a2a10')), k.opacity(treeOp), k.z(layer)])
|
|
for (let c = 0; c < 3 + layer; c++) {
|
|
const cr = (10 + Math.random() * 14) * (layer === 0 ? 0.6 : layer === 1 ? 0.8 : 1)
|
|
k.add([
|
|
k.circle(cr), k.pos(tx + trunkW / 2 + (Math.random() - 0.5) * 10, GROUND_Y - th - cr * 0.5 + c * 4),
|
|
k.anchor('center'), k.color(safeColor(k, layer === 0 ? '#0a1a08' : layer === 1 ? '#122a0e' : '#1a3a10')), k.opacity(treeOp * 0.8), k.z(layer),
|
|
])
|
|
}
|
|
}
|
|
}
|
|
// Undergrowth
|
|
for (let i = 0; i < 10; i++) {
|
|
k.add([k.circle(6 + Math.random() * 8), k.pos(Math.random() * W, GROUND_Y - 3), k.anchor('bot'), k.color(safeColor(k, '#1a3a10')), k.opacity(0.3), k.z(3)])
|
|
}
|
|
// Mushrooms
|
|
for (let i = 0; i < 5; i++) {
|
|
const mx = Math.random() * W
|
|
k.add([k.rect(2, 5), k.pos(mx, GROUND_Y - 5), k.color(safeColor(k, '#aa8866')), k.opacity(0.25), k.z(3)])
|
|
k.add([k.circle(4), k.pos(mx + 1, GROUND_Y - 6), k.anchor('center'), k.color(safeColor(k, Math.random() > 0.5 ? '#cc3333' : '#ffaa22')), k.opacity(0.2), k.z(3)])
|
|
}
|
|
// Fireflies
|
|
for (let i = 0; i < 15; i++) {
|
|
const ff = k.add([
|
|
k.circle(1.5), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 50)),
|
|
k.color(safeColor(k, '#ccff44')), k.opacity(0), k.z(3),
|
|
])
|
|
const bx = ff.pos.x, by = ff.pos.y
|
|
ff.onUpdate(() => {
|
|
ff.opacity = Math.max(0, Math.sin(k.time() * 1.5 + i * 1.8) * 0.5)
|
|
ff.pos.x = bx + Math.sin(k.time() * 0.5 + i) * 15
|
|
ff.pos.y = by + Math.cos(k.time() * 0.4 + i) * 10
|
|
})
|
|
}
|
|
// Light rays through canopy
|
|
for (let i = 0; i < 5; i++) {
|
|
const rx = W * (0.15 + i * 0.18) + (Math.random() - 0.5) * 20
|
|
const ray = k.add([k.rect(8, GROUND_Y * 0.7), k.pos(rx, 0), k.color(safeColor(k, '#aaff44')), k.opacity(0.03), k.z(2), k.rotate(3 - Math.random() * 6)])
|
|
ray.onUpdate(() => { ray.opacity = 0.02 + Math.sin(k.time() * 0.3 + i) * 0.015 })
|
|
}
|
|
// Fallen log
|
|
k.add([k.rect(45, 6), k.pos(W * 0.55, GROUND_Y - 4), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(3), k.rotate(3)])
|
|
|
|
} else if (a === 'jungle') {
|
|
// Dense vine canopy
|
|
for (let i = 0; i < 12; i++) {
|
|
const vx = 15 + i * (W / 12) + (Math.random() - 0.5) * 12
|
|
const vineLen = 30 + Math.random() * 90
|
|
for (let s = 0; s < vineLen; s += 4) {
|
|
const vineEl = k.add([k.rect(2, 5), k.pos(vx + Math.sin(s * 0.15) * 4, s), k.color(safeColor(k, '#1a4a10')), k.opacity(0.3), k.z(1)])
|
|
if (Math.random() < 0.08) {
|
|
const basePx = vineEl.pos.x
|
|
vineEl.onUpdate(() => { vineEl.pos.x = basePx + Math.sin(k.time() * 0.5 + s) * 2 })
|
|
}
|
|
}
|
|
for (let l = 0; l < 2; l++) {
|
|
k.add([k.circle(5 + Math.random() * 4), k.pos(vx + (Math.random() - 0.5) * 8, vineLen + l * 6), k.color(safeColor(k, '#22aa22')), k.opacity(0.22), k.z(1)])
|
|
}
|
|
}
|
|
// Large tropical leaves at edges
|
|
for (const side of [0, W - 30]) {
|
|
for (let l = 0; l < 4; l++) {
|
|
const ly = 15 + l * (GROUND_Y / 5)
|
|
const leaf = k.add([k.rect(35, 8), k.pos(side, ly), k.color(safeColor(k, '#1a5510')), k.opacity(0.25), k.z(3), k.rotate(side === 0 ? 15 : -15)])
|
|
leaf.onUpdate(() => { leaf.angle = (side === 0 ? 15 : -15) + Math.sin(k.time() * 0.6 + l) * 2 })
|
|
}
|
|
}
|
|
// Exotic flowers
|
|
for (let i = 0; i < 6; i++) {
|
|
const fx = Math.random() * W
|
|
const fy = 20 + Math.random() * (GROUND_Y - 40)
|
|
k.add([k.circle(3), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k, ['#ff4488', '#ff8844', '#ffcc00', '#ff44ff', '#44ffcc', '#ff2222'][i])), k.opacity(0.2), k.z(2)])
|
|
}
|
|
// Ancient stone ruins peeking through
|
|
const ruinX = W * 0.5
|
|
k.add([k.rect(8, 40), k.pos(ruinX, GROUND_Y - 38), k.color(safeColor(k, '#444444')), k.opacity(0.15), k.z(0)])
|
|
k.add([k.rect(8, 35), k.pos(ruinX + 25, GROUND_Y - 33), k.color(safeColor(k, '#444444')), k.opacity(0.12), k.z(0)])
|
|
k.add([k.rect(35, 5), k.pos(ruinX - 2, GROUND_Y - 40), k.color(safeColor(k, '#444444')), k.opacity(0.1), k.z(0)])
|
|
// Thick ground fog
|
|
for (let i = 0; i < 10; i++) {
|
|
const mist = k.add([
|
|
k.circle(40 + Math.random() * 35), k.pos(Math.random() * W, GROUND_Y - 12),
|
|
k.color(safeColor(k, '#1a3818')), k.opacity(0.22), k.z(3),
|
|
])
|
|
const bx = mist.pos.x
|
|
mist.onUpdate(() => { mist.pos.x = bx + Math.sin(k.time() * 0.12 + i) * 18 })
|
|
}
|
|
// Butterfly particles
|
|
for (let i = 0; i < 4; i++) {
|
|
const bf = k.add([
|
|
k.circle(1.5), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 60)),
|
|
k.color(safeColor(k, ['#4488ff', '#ff8844', '#ffcc00', '#ff44ff'][i])), k.opacity(0.25), k.z(3),
|
|
])
|
|
const bfx = bf.pos.x, bfy = bf.pos.y
|
|
bf.onUpdate(() => {
|
|
bf.pos.x = bfx + Math.sin(k.time() * 1.2 + i * 3) * 30
|
|
bf.pos.y = bfy + Math.cos(k.time() * 0.8 + i * 2) * 20
|
|
})
|
|
}
|
|
|
|
} else if (a === 'outer_space') {
|
|
// Dense star field
|
|
for (let i = 0; i < 100; i++) {
|
|
const star = k.add([
|
|
k.circle(0.5 + Math.random() * 1.5),
|
|
k.pos(Math.random() * W, Math.random() * H),
|
|
k.color(safeColor(k, i % 8 === 0 ? '#ffccaa' : i % 5 === 0 ? '#aaaaff' : '#ffffff')),
|
|
k.opacity(0.15 + Math.random() * 0.4), k.z(1),
|
|
])
|
|
star.onUpdate(() => { star.opacity = 0.12 + Math.sin(k.time() * (0.8 + Math.random()) + i) * 0.15 })
|
|
}
|
|
// Nebula clouds
|
|
const nebulaColors = ['#4400aa', '#aa0066', '#0044aa', '#006644', '#660044']
|
|
for (let i = 0; i < 5; i++) {
|
|
for (let blob = 0; blob < 4; blob++) {
|
|
k.add([
|
|
k.circle(30 + Math.random() * 50),
|
|
k.pos(W * (0.1 + i * 0.2) + blob * 18, H * 0.25 + Math.random() * 60 + blob * 12),
|
|
k.color(safeColor(k, nebulaColors[i % nebulaColors.length])),
|
|
k.opacity(0.04 + blob * 0.012), k.z(0),
|
|
])
|
|
}
|
|
}
|
|
// Floating asteroids
|
|
for (let i = 0; i < 6; i++) {
|
|
const ast = k.add([
|
|
k.circle(4 + Math.random() * 10),
|
|
k.pos(Math.random() * W, 15 + Math.random() * (GROUND_Y - 30)),
|
|
k.color(safeColor(k, Math.random() > 0.5 ? '#555555' : '#3a3a3a')), k.opacity(0.35), k.z(1),
|
|
])
|
|
k.add([k.circle(2), k.pos(ast.pos.x + 2, ast.pos.y - 1), k.color(safeColor(k, '#2a2a2a')), k.opacity(0.2), k.z(1)])
|
|
const baseX = ast.pos.x, baseY = ast.pos.y
|
|
ast.onUpdate(() => {
|
|
ast.pos.x = baseX + Math.sin(k.time() * 0.15 + i * 2) * 18
|
|
ast.pos.y = baseY + Math.cos(k.time() * 0.12 + i) * 12
|
|
})
|
|
}
|
|
// Distant planet with rings
|
|
const planetX = W * 0.75, planetY = GROUND_Y * 0.25
|
|
k.add([k.circle(22), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k, '#334466')), k.opacity(0.25), k.z(0)])
|
|
k.add([k.circle(24), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k, '#223344')), k.opacity(0.1), k.z(0)])
|
|
for (let r = 0; r < 3; r++) {
|
|
k.add([k.rect(50 + r * 8, 2), k.pos(planetX - 25 - r * 4, planetY - 1 + r), k.color(safeColor(k, '#556688')), k.opacity(0.1 - r * 0.02), k.z(0), k.rotate(15)])
|
|
}
|
|
// Shooting stars
|
|
for (let i = 0; i < 2; i++) {
|
|
const shootStar = k.add([
|
|
k.rect(12, 1), k.pos(-20, Math.random() * GROUND_Y * 0.5),
|
|
k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(2), k.rotate(30),
|
|
])
|
|
let timer = Math.random() * 10
|
|
shootStar.onUpdate(() => {
|
|
timer -= k.dt()
|
|
if (timer <= 0) {
|
|
shootStar.pos.x = -20
|
|
shootStar.pos.y = Math.random() * GROUND_Y * 0.4
|
|
shootStar.opacity = 0.5
|
|
timer = 5 + Math.random() * 10
|
|
}
|
|
if (shootStar.opacity > 0) {
|
|
shootStar.pos.x += 200 * k.dt()
|
|
shootStar.pos.y += 80 * k.dt()
|
|
shootStar.opacity -= 0.8 * k.dt()
|
|
}
|
|
})
|
|
}
|
|
|
|
} else if (a === 'colosseum') {
|
|
// Roman arches along the back
|
|
for (let i = 0; i < 8; i++) {
|
|
const ax = 20 + i * (W / 8)
|
|
const aw = W / 8 - 8
|
|
k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k, '#3a2a18')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax + aw - 6, GROUND_Y * 0.2), k.color(safeColor(k, '#3a2a18')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(aw, 6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k, '#4a3a20')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(aw - 12, GROUND_Y * 0.5), k.pos(ax + 6, GROUND_Y * 0.25), k.color(safeColor(k, '#0a0800')), k.opacity(0.3), k.z(0)])
|
|
k.add([k.rect(10, 3), k.pos(ax - 2, GROUND_Y * 0.2), k.color(safeColor(k, '#5a4a30')), k.opacity(0.35), k.z(1)])
|
|
k.add([k.rect(10, 3), k.pos(ax + aw - 10, GROUND_Y * 0.2), k.color(safeColor(k, '#5a4a30')), k.opacity(0.35), k.z(1)])
|
|
}
|
|
// Torch brackets on pillars
|
|
for (let i = 0; i < 4; i++) {
|
|
const tx = W * (0.15 + i * 0.25)
|
|
const ty = GROUND_Y * 0.35
|
|
k.add([k.rect(3, 12), k.pos(tx, ty), k.color(safeColor(k, '#554422')), k.opacity(0.4), k.z(2)])
|
|
const flame = k.add([k.circle(5), k.pos(tx + 1, ty - 3), k.anchor('center'), k.color(safeColor(k, '#ff6600')), k.opacity(0.4), k.z(2)])
|
|
flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + i * 2) * 0.15 })
|
|
}
|
|
// Crowd silhouettes in upper tiers
|
|
for (let row = 0; row < 2; row++) {
|
|
for (let i = 0; i < 20; i++) {
|
|
k.add([
|
|
k.circle(2 + Math.random()), k.pos(15 + i * (W / 20), GROUND_Y * 0.08 + row * 12),
|
|
k.color(safeColor(k, '#2a1a10')), k.opacity(0.2), k.z(0),
|
|
])
|
|
}
|
|
}
|
|
// Crumbled stone debris
|
|
for (let i = 0; i < 8; i++) {
|
|
k.add([
|
|
k.rect(5 + Math.random() * 10, 3 + Math.random() * 6),
|
|
k.pos(Math.random() * W, GROUND_Y - 5 - Math.random() * 10),
|
|
k.color(safeColor(k, '#5a4a30')), k.opacity(0.25), k.z(2), k.rotate(Math.random() * 40 - 20),
|
|
])
|
|
}
|
|
// Sand dust particles
|
|
for (let i = 0; i < 6; i++) {
|
|
const dust = k.add([
|
|
k.circle(2 + Math.random() * 3), k.pos(Math.random() * W, GROUND_Y - 15 - Math.random() * 20),
|
|
k.color(safeColor(k, '#c0a060')), k.opacity(0.1), k.z(3),
|
|
])
|
|
const dx = dust.pos.x
|
|
dust.onUpdate(() => { dust.pos.x = dx + Math.sin(k.time() * 0.5 + i) * 12; dust.opacity = 0.05 + Math.sin(k.time() + i) * 0.05 })
|
|
}
|
|
|
|
} else if (a === 'haunted_mansion') {
|
|
// Gothic windows with moonlight
|
|
for (let i = 0; i < 5; i++) {
|
|
const wx = W * (0.1 + i * 0.2)
|
|
const wy = GROUND_Y * 0.2
|
|
k.add([k.rect(20, 35), k.pos(wx, wy), k.color(safeColor(k, '#0a0510')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(22, 1), k.pos(wx - 1, wy), k.color(safeColor(k, '#2a1830')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(22, 1), k.pos(wx - 1, wy + 35), k.color(safeColor(k, '#2a1830')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(1, 35), k.pos(wx + 10, wy), k.color(safeColor(k, '#2a1830')), k.opacity(0.3), k.z(1)])
|
|
k.add([k.rect(20, 1), k.pos(wx, wy + 17), k.color(safeColor(k, '#2a1830')), k.opacity(0.3), k.z(1)])
|
|
const glow = k.add([k.rect(16, 30), k.pos(wx + 2, wy + 3), k.color(safeColor(k, '#8844cc')), k.opacity(0.06), k.z(1)])
|
|
glow.onUpdate(() => { glow.opacity = 0.04 + Math.sin(k.time() * 0.5 + i) * 0.03 })
|
|
}
|
|
// Moon behind a window
|
|
k.add([k.circle(12), k.pos(W * 0.5 + 10, GROUND_Y * 0.26), k.anchor('center'), k.color(safeColor(k, '#ccccdd')), k.opacity(0.15), k.z(0)])
|
|
// Cobwebs
|
|
for (const cx of [15, W - 35]) {
|
|
for (let strand = 0; strand < 5; strand++) {
|
|
k.add([k.rect(20 + strand * 5, 1), k.pos(cx, 3 + strand * 4), k.color(safeColor(k, '#666666')), k.opacity(0.1), k.z(1), k.rotate(cx < W / 2 ? 28 + strand * 5 : -28 - strand * 5)])
|
|
}
|
|
}
|
|
// Candelabras
|
|
for (const cx of [W * 0.25, W * 0.75]) {
|
|
k.add([k.rect(2, 20), k.pos(cx, GROUND_Y - 20), k.color(safeColor(k, '#666644')), k.opacity(0.3), k.z(1)])
|
|
k.add([k.rect(12, 2), k.pos(cx - 5, GROUND_Y - 20), k.color(safeColor(k, '#666644')), k.opacity(0.3), k.z(1)])
|
|
for (let c = 0; c < 3; c++) {
|
|
const candleX = cx - 5 + c * 5
|
|
const cFlame = k.add([k.circle(2.5), k.pos(candleX + 1, GROUND_Y - 23), k.anchor('center'), k.color(safeColor(k, '#ffaa44')), k.opacity(0.4), k.z(2)])
|
|
cFlame.onUpdate(() => { cFlame.opacity = 0.3 + Math.sin(k.time() * 4 + c + cx) * 0.15 })
|
|
}
|
|
}
|
|
// Floating ghost particles
|
|
for (let i = 0; i < 8; i++) {
|
|
const ghost = k.add([
|
|
k.circle(3 + Math.random() * 3), k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k, '#aabbcc')), k.opacity(0), k.z(2),
|
|
])
|
|
const gx = ghost.pos.x, gy = ghost.pos.y
|
|
ghost.onUpdate(() => {
|
|
ghost.opacity = Math.max(0, Math.sin(k.time() * 0.6 + i * 2) * 0.15)
|
|
ghost.pos.x = gx + Math.sin(k.time() * 0.3 + i) * 20
|
|
ghost.pos.y = gy + Math.cos(k.time() * 0.25 + i) * 15
|
|
})
|
|
}
|
|
// Bats flying
|
|
for (let i = 0; i < 3; i++) {
|
|
const bat = k.add([k.rect(5, 2), k.pos(Math.random() * W, 15 + Math.random() * 40), k.color(safeColor(k, '#111111')), k.opacity(0.3), k.z(2)])
|
|
const bx = bat.pos.x, by = bat.pos.y
|
|
bat.onUpdate(() => {
|
|
bat.pos.x = bx + Math.sin(k.time() * 1.5 + i * 3) * 40
|
|
bat.pos.y = by + Math.cos(k.time() * 2 + i * 2) * 10
|
|
})
|
|
}
|
|
|
|
} else if (a === 'concert_hall') {
|
|
// Stage lights with sweeping cones
|
|
const lightColors = ['#ff2288', '#22ff88', '#4488ff', '#ffaa22', '#ff44ff', '#44ffff']
|
|
for (let i = 0; i < 6; i++) {
|
|
const lx = W * (0.1 + i * 0.15)
|
|
const cone = k.add([
|
|
k.rect(30, GROUND_Y * 0.7), k.pos(lx, 10), k.anchor('top'),
|
|
k.color(safeColor(k, lightColors[i])), k.opacity(0.04), k.z(2), k.rotate(0),
|
|
])
|
|
k.add([k.circle(4), k.pos(lx + 15, 8), k.anchor('center'), k.color(safeColor(k, lightColors[i])), k.opacity(0.4), k.z(2)])
|
|
const baseAngle = (i - 3) * 5
|
|
cone.onUpdate(() => {
|
|
cone.opacity = 0.02 + Math.sin(k.time() * 1.5 + i * 1.2) * 0.025
|
|
cone.angle = baseAngle + Math.sin(k.time() * 0.4 + i) * 8
|
|
})
|
|
}
|
|
// Speaker stacks
|
|
for (const sx of [10, W - 30]) {
|
|
for (let s = 0; s < 4; s++) {
|
|
k.add([k.rect(20, 18), k.pos(sx, GROUND_Y - 20 - s * 20), k.color(safeColor(k, '#111111')), k.opacity(0.5), k.z(1)])
|
|
k.add([k.circle(6), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k, '#222222')), k.opacity(0.4), k.z(1)])
|
|
const speakerCone = k.add([k.circle(4), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k, '#1a1a1a')), k.opacity(0.3), k.z(1), k.scale(1)])
|
|
speakerCone.onUpdate(() => { speakerCone.scale = k.vec2(1 + Math.sin(k.time() * 8 + s) * 0.05) })
|
|
}
|
|
}
|
|
// Crowd silhouettes
|
|
for (let i = 0; i < 30; i++) {
|
|
const cx = 5 + i * (W / 30)
|
|
const ch = 6 + Math.random() * 4
|
|
k.add([k.circle(2.5), k.pos(cx, GROUND_Y + 8), k.anchor('center'), k.color(safeColor(k, '#111111')), k.opacity(0.3), k.z(3)])
|
|
k.add([k.rect(4, ch), k.pos(cx - 2, GROUND_Y + 10), k.color(safeColor(k, '#111111')), k.opacity(0.25), k.z(3)])
|
|
}
|
|
// Smoke machine haze
|
|
for (let i = 0; i < 6; i++) {
|
|
const haze = k.add([
|
|
k.circle(30 + Math.random() * 25), k.pos(Math.random() * W, GROUND_Y - 8),
|
|
k.color(safeColor(k, '#20102a')), k.opacity(0.18), k.z(3),
|
|
])
|
|
const hx = haze.pos.x
|
|
haze.onUpdate(() => { haze.pos.x = hx + Math.sin(k.time() * 0.15 + i) * 15 })
|
|
}
|
|
// Laser beam sweep
|
|
const laser = k.add([k.rect(1, GROUND_Y), k.pos(W / 2, 0), k.color(safeColor(k, '#00ff00')), k.opacity(0.06), k.z(2)])
|
|
laser.onUpdate(() => { laser.pos.x = W / 2 + Math.sin(k.time() * 0.8) * (W * 0.4) })
|
|
|
|
} else if (a === 'sports_arena') {
|
|
// Stadium seating tiers with crowd
|
|
for (let tier = 0; tier < 5; tier++) {
|
|
const ty = GROUND_Y * 0.1 + tier * (GROUND_Y * 0.13)
|
|
k.add([k.rect(W, GROUND_Y * 0.1), k.pos(0, ty), k.color(safeColor(k, tier % 2 === 0 ? '#1a2a4a' : '#152040')), k.opacity(0.3), k.z(1)])
|
|
for (let i = 0; i < 18; i++) {
|
|
k.add([
|
|
k.circle(1.5 + Math.random()), k.pos(15 + i * (W / 18), ty + GROUND_Y * 0.04),
|
|
k.color(safeColor(k, ['#cc3333', '#3333cc', '#33cc33', '#cccc33', '#ffffff'][Math.floor(Math.random() * 5)])),
|
|
k.opacity(0.12), k.z(1),
|
|
])
|
|
}
|
|
}
|
|
// Floodlights
|
|
for (const fx of [W * 0.05, W * 0.95]) {
|
|
k.add([k.rect(3, GROUND_Y * 0.7), k.pos(fx, GROUND_Y * 0.1), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(2)])
|
|
const light = k.add([k.circle(5), k.pos(fx + 1, GROUND_Y * 0.08), k.anchor('center'), k.color(safeColor(k, '#ffffff')), k.opacity(0.5), k.z(2)])
|
|
k.add([k.rect(40, GROUND_Y * 0.6), k.pos(fx - 18, GROUND_Y * 0.12), k.color(safeColor(k, '#ffffff')), k.opacity(0.015), k.z(0)])
|
|
light.onUpdate(() => { light.opacity = 0.4 + Math.sin(k.time() * 0.5 + fx) * 0.1 })
|
|
}
|
|
// Scoreboard
|
|
k.add([k.rect(50, 18), k.pos(W / 2 - 25, 8), k.color(safeColor(k, '#0a0a20')), k.opacity(0.5), k.z(2)])
|
|
k.add([k.rect(48, 16), k.pos(W / 2 - 24, 9), k.color(safeColor(k, '#111133')), k.opacity(0.4), k.z(2)])
|
|
const scoreGlow = k.add([k.rect(20, 8), k.pos(W / 2 - 10, 13), k.color(safeColor(k, '#ff3333')), k.opacity(0.12), k.z(2)])
|
|
scoreGlow.onUpdate(() => { scoreGlow.opacity = 0.08 + Math.sin(k.time() * 2) * 0.05 })
|
|
// Camera flashes
|
|
for (let i = 0; i < 5; i++) {
|
|
const flash = k.add([
|
|
k.circle(2), k.pos(Math.random() * W, GROUND_Y * (0.15 + Math.random() * 0.5)),
|
|
k.color(safeColor(k, '#ffffff')), k.opacity(0), k.z(3),
|
|
])
|
|
flash.onUpdate(() => { flash.opacity = Math.random() > 0.995 ? 0.4 : 0 })
|
|
}
|
|
|
|
} else if (a === 'kitchen_stadium') {
|
|
// Tiled wall
|
|
for (let row = 0; row < 6; row++) {
|
|
for (let col = 0; col < 14; col++) {
|
|
k.add([
|
|
k.rect(W / 14 - 1, GROUND_Y / 6 - 1), k.pos(col * (W / 14), row * (GROUND_Y / 6)),
|
|
k.color(safeColor(k, (row + col) % 2 === 0 ? '#1a1210' : '#1e1614')), k.opacity(0.2), k.z(0),
|
|
])
|
|
}
|
|
}
|
|
// Stove with burners
|
|
k.add([k.rect(45, 35), k.pos(W * 0.06, GROUND_Y - 35), k.color(safeColor(k, '#1a1010')), k.opacity(0.45), k.z(1)])
|
|
k.add([k.rect(43, 2), k.pos(W * 0.06 + 1, GROUND_Y - 35), k.color(safeColor(k, '#333333')), k.opacity(0.3), k.z(1)])
|
|
for (let b = 0; b < 4; b++) {
|
|
const flame = k.add([k.circle(4), k.pos(W * 0.06 + 6 + b * 10, GROUND_Y - 37), k.anchor('center'), k.color(safeColor(k, '#ff4400')), k.opacity(0.4), k.z(2)])
|
|
const innerFlame = k.add([k.circle(2), k.pos(W * 0.06 + 6 + b * 10, GROUND_Y - 38), k.anchor('center'), k.color(safeColor(k, '#ffcc00')), k.opacity(0.3), k.z(2)])
|
|
flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + b) * 0.15; innerFlame.opacity = 0.2 + Math.sin(k.time() * 7 + b) * 0.1 })
|
|
}
|
|
// Hanging pots and pans
|
|
k.add([k.rect(W * 0.5, 2), k.pos(W * 0.2, 8), k.color(safeColor(k, '#444444')), k.opacity(0.3), k.z(1)])
|
|
for (let i = 0; i < 6; i++) {
|
|
const px = W * (0.22 + i * 0.08)
|
|
const hookLen = 12 + Math.random() * 10
|
|
k.add([k.rect(1, hookLen), k.pos(px, 10), k.color(safeColor(k, '#444444')), k.opacity(0.25), k.z(1)])
|
|
k.add([k.circle(7 + Math.random() * 4), k.pos(px, 10 + hookLen + 5), k.anchor('center'), k.color(safeColor(k, i % 2 === 0 ? '#555555' : '#664422')), k.opacity(0.25), k.z(1)])
|
|
}
|
|
// Knife rack
|
|
k.add([k.rect(3, 25), k.pos(W * 0.85, GROUND_Y * 0.3), k.color(safeColor(k, '#553322')), k.opacity(0.35), k.z(1)])
|
|
for (let i = 0; i < 4; i++) {
|
|
k.add([k.rect(2, 10 + Math.random() * 5), k.pos(W * 0.85 + 1, GROUND_Y * 0.32 + i * 5), k.color(safeColor(k, '#aaaaaa')), k.opacity(0.2), k.z(1)])
|
|
}
|
|
// Steam rising
|
|
for (let i = 0; i < 4; i++) {
|
|
const steam = k.add([
|
|
k.circle(5 + Math.random() * 5), k.pos(W * 0.06 + 10 + i * 10, GROUND_Y - 40),
|
|
k.color(safeColor(k, '#ffffff')), k.opacity(0.06), k.z(3),
|
|
])
|
|
const bx = steam.pos.x
|
|
steam.onUpdate(() => {
|
|
steam.pos.y -= 10 * k.dt()
|
|
steam.pos.x = bx + Math.sin(k.time() * 2 + i) * 4
|
|
steam.opacity *= 0.995
|
|
if (steam.pos.y < GROUND_Y - 80) { steam.pos.y = GROUND_Y - 40; steam.opacity = 0.06 }
|
|
})
|
|
}
|
|
|
|
} else if (a === 'junkyard') {
|
|
// Rusted crane silhouette
|
|
k.add([k.rect(6, GROUND_Y * 0.7), k.pos(W * 0.85, GROUND_Y * 0.1), k.color(safeColor(k, '#443322')), k.opacity(0.3), k.z(0)])
|
|
k.add([k.rect(W * 0.3, 4), k.pos(W * 0.6, GROUND_Y * 0.1), k.color(safeColor(k, '#443322')), k.opacity(0.25), k.z(0)])
|
|
const cable = k.add([k.rect(1, 35), k.pos(W * 0.65, GROUND_Y * 0.12), k.color(safeColor(k, '#555555')), k.opacity(0.2), k.z(0)])
|
|
cable.onUpdate(() => { cable.pos.x = W * 0.65 + Math.sin(k.time() * 0.3) * 5 })
|
|
// Piles of scrap
|
|
for (let pile = 0; pile < 8; pile++) {
|
|
const px = Math.random() * W
|
|
const py = GROUND_Y - 8
|
|
for (let j = 0; j < 7; j++) {
|
|
k.add([
|
|
k.rect(8 + Math.random() * 18, 4 + Math.random() * 10),
|
|
k.pos(px + (Math.random() - 0.5) * 25, py - j * 6 - Math.random() * 5),
|
|
k.color(safeColor(k, ['#554433', '#665544', '#443322', '#776655', '#332211', '#887766', '#553322'][j % 7])),
|
|
k.opacity(0.3), k.z(1), k.rotate(Math.random() * 40 - 20),
|
|
])
|
|
}
|
|
}
|
|
// Old car silhouette
|
|
k.add([k.rect(35, 15), k.pos(W * 0.15, GROUND_Y - 15), k.color(safeColor(k, '#2a2a1a')), k.opacity(0.3), k.z(1)])
|
|
k.add([k.rect(25, 10), k.pos(W * 0.15 + 5, GROUND_Y - 25), k.color(safeColor(k, '#2a2a1a')), k.opacity(0.25), k.z(1)])
|
|
k.add([k.circle(5), k.pos(W * 0.15 + 6, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k, '#1a1a0a')), k.opacity(0.3), k.z(1)])
|
|
k.add([k.circle(5), k.pos(W * 0.15 + 29, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k, '#1a1a0a')), k.opacity(0.3), k.z(1)])
|
|
// Sparking wires
|
|
for (let i = 0; i < 3; i++) {
|
|
const spark = k.add([
|
|
k.circle(2), k.pos(W * (0.3 + i * 0.2), GROUND_Y - 20 - Math.random() * 30),
|
|
k.color(safeColor(k, '#ffaa00')), k.opacity(0), k.z(3),
|
|
])
|
|
spark.onUpdate(() => { spark.opacity = Math.random() > 0.96 ? 0.5 : 0 })
|
|
}
|
|
// Smoke/steam wisps
|
|
for (let i = 0; i < 4; i++) {
|
|
const smoke = k.add([
|
|
k.circle(10 + Math.random() * 10), k.pos(Math.random() * W, GROUND_Y - 5),
|
|
k.color(safeColor(k, '#333322')), k.opacity(0.12), k.z(2),
|
|
])
|
|
const sx = smoke.pos.x
|
|
smoke.onUpdate(() => {
|
|
smoke.pos.y -= 5 * k.dt()
|
|
smoke.pos.x = sx + Math.sin(k.time() * 0.5 + i) * 6
|
|
if (smoke.pos.y < GROUND_Y - 60) { smoke.pos.y = GROUND_Y - 5; smoke.opacity = 0.12 }
|
|
})
|
|
}
|
|
|
|
} else if (a === 'meme_dimension') {
|
|
// Floating emoji shapes (pulsing)
|
|
for (let i = 0; i < 12; i++) {
|
|
const emoji = k.add([
|
|
k.circle(6 + Math.random() * 6), k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k, ['#ffcc00', '#ff4444', '#44ff44', '#4444ff', '#ff44ff', '#44ffff'][i % 6])), k.opacity(0.12), k.z(1), k.scale(1),
|
|
])
|
|
const bx = emoji.pos.x, by = emoji.pos.y
|
|
emoji.onUpdate(() => {
|
|
emoji.pos.x = bx + Math.sin(k.time() * 0.4 + i) * 15
|
|
emoji.pos.y = by + Math.cos(k.time() * 0.3 + i) * 10
|
|
emoji.scale = k.vec2(1 + Math.sin(k.time() * 1.5 + i) * 0.15)
|
|
})
|
|
}
|
|
// Glitch bands
|
|
for (let i = 0; i < 8; i++) {
|
|
const band = k.add([
|
|
k.rect(W, 2 + Math.random() * 4), k.pos(0, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k, Math.random() > 0.5 ? '#ff44ff' : '#44ffff')), k.opacity(0), k.z(2),
|
|
])
|
|
band.onUpdate(() => {
|
|
band.opacity = Math.random() > 0.97 ? 0.12 + Math.random() * 0.08 : 0
|
|
if (band.opacity > 0) band.pos.x = (Math.random() - 0.5) * 10
|
|
else band.pos.x = 0
|
|
})
|
|
}
|
|
// Rainbow pixel streaks
|
|
const rainbowC = ['#ff0000', '#ff8800', '#ffff00', '#00ff00', '#0088ff', '#8800ff']
|
|
for (let i = 0; i < rainbowC.length; i++) {
|
|
const streak = k.add([
|
|
k.rect(W, 3), k.pos(0, GROUND_Y * 0.3 + i * 5),
|
|
k.color(safeColor(k, rainbowC[i])), k.opacity(0.04), k.z(0),
|
|
])
|
|
streak.onUpdate(() => { streak.opacity = 0.02 + Math.sin(k.time() * 2 + i) * 0.02 })
|
|
}
|
|
// Spinning geometric shapes
|
|
for (let i = 0; i < 4; i++) {
|
|
const geo = k.add([
|
|
k.rect(8 + Math.random() * 8, 8 + Math.random() * 8),
|
|
k.pos(W * (0.2 + i * 0.2), GROUND_Y * (0.3 + Math.random() * 0.3)),
|
|
k.anchor('center'), k.color(safeColor(k, '#ff44ff')), k.opacity(0.08), k.z(1), k.rotate(0),
|
|
])
|
|
geo.onUpdate(() => { geo.angle += (30 + i * 15) * k.dt() })
|
|
}
|
|
// VHS tracking lines
|
|
const vhs = k.add([k.rect(W, 6), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(3)])
|
|
vhs.onUpdate(() => {
|
|
vhs.pos.y = (vhs.pos.y + 80 * k.dt()) % (GROUND_Y + 20)
|
|
vhs.opacity = Math.random() > 0.9 ? 0.15 : 0
|
|
})
|
|
// RGB noise flickers
|
|
for (let i = 0; i < 6; i++) {
|
|
const noise = k.add([
|
|
k.rect(3 + Math.random() * 8, 3 + Math.random() * 8),
|
|
k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k, ['#ff0000', '#00ff00', '#0000ff'][i % 3])), k.opacity(0), k.z(2),
|
|
])
|
|
noise.onUpdate(() => {
|
|
noise.opacity = Math.random() > 0.98 ? 0.1 : 0
|
|
if (noise.opacity > 0) { noise.pos.x = Math.random() * W; noise.pos.y = Math.random() * GROUND_Y }
|
|
})
|
|
}
|
|
|
|
} else if (a === 'space_station') {
|
|
// Structural beams with rivets
|
|
for (let i = 0; i < 6; i++) {
|
|
const bx = W * (0.15 + i * 0.14)
|
|
k.add([k.rect(5, GROUND_Y), k.pos(bx, 0), k.color(safeColor(k, '#1a1a30')), k.opacity(0.3), k.z(1)])
|
|
for (let r = 0; r < 8; r++) {
|
|
k.add([k.circle(1), k.pos(bx + 2, r * (GROUND_Y / 8) + 5), k.anchor('center'), k.color(safeColor(k, '#2a2a40')), k.opacity(0.25), k.z(1)])
|
|
}
|
|
k.add([k.rect(W * 0.14, 3), k.pos(bx, GROUND_Y * 0.25), k.color(safeColor(k, '#1a1a30')), k.opacity(0.25), k.z(1)])
|
|
k.add([k.rect(W * 0.14, 3), k.pos(bx, GROUND_Y * 0.6), k.color(safeColor(k, '#1a1a30')), k.opacity(0.2), k.z(1)])
|
|
}
|
|
// Viewport
|
|
const vpX = W * 0.5, vpY = GROUND_Y * 0.3
|
|
k.add([k.circle(40), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k, '#000020')), k.opacity(0.5), k.z(0)])
|
|
k.add([k.circle(42), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k, '#2a2a50')), k.opacity(0.3), k.z(0)])
|
|
for (let s = 0; s < 15; s++) {
|
|
const dist = Math.random() * 35
|
|
const angle = Math.random() * Math.PI * 2
|
|
const sx = vpX + Math.cos(angle) * dist
|
|
const sy = vpY + Math.sin(angle) * dist
|
|
const vpStar = k.add([k.circle(0.8 + Math.random()), k.pos(sx, sy), k.color(safeColor(k, '#ffffff')), k.opacity(0.3), k.z(0)])
|
|
vpStar.onUpdate(() => { vpStar.opacity = 0.15 + Math.sin(k.time() * 2 + s) * 0.15 })
|
|
}
|
|
// Earth through viewport
|
|
k.add([k.circle(15), k.pos(vpX + 10, vpY + 5), k.anchor('center'), k.color(safeColor(k, '#2244aa')), k.opacity(0.15), k.z(0)])
|
|
k.add([k.circle(6), k.pos(vpX + 7, vpY + 2), k.anchor('center'), k.color(safeColor(k, '#22aa44')), k.opacity(0.08), k.z(0)])
|
|
// Control panels
|
|
for (const px of [W * 0.08, W * 0.88]) {
|
|
k.add([k.rect(25, 40), k.pos(px, GROUND_Y * 0.4), k.color(safeColor(k, '#0a0a20')), k.opacity(0.4), k.z(1)])
|
|
for (let led = 0; led < 8; led++) {
|
|
const ledEl = k.add([
|
|
k.rect(2, 2), k.pos(px + 4 + (led % 4) * 5, GROUND_Y * 0.42 + Math.floor(led / 4) * 6),
|
|
k.color(safeColor(k, ['#00ff88', '#ff4444', '#ffaa00', '#4488ff'][led % 4])), k.opacity(0.4), k.z(2),
|
|
])
|
|
ledEl.onUpdate(() => { ledEl.opacity = Math.random() > 0.05 ? 0.4 : 0.1 })
|
|
}
|
|
}
|
|
// Warning stripes
|
|
for (let i = 0; i < 8; i++) {
|
|
k.add([k.rect(8, 3), k.pos(W * 0.3 + i * 12, GROUND_Y - 3), k.color(safeColor(k, i % 2 === 0 ? '#ffaa00' : '#000000')), k.opacity(0.12), k.z(3)])
|
|
}
|
|
|
|
} else if (a === 'savanna') {
|
|
// Distant mountain range
|
|
for (let i = 0; i < 5; i++) {
|
|
const mx = W * (0.1 + i * 0.2) + (Math.random() - 0.5) * 30
|
|
const mh = 30 + Math.random() * 40
|
|
k.add([k.circle(mh), k.pos(mx, GROUND_Y - mh * 0.3), k.anchor('center'), k.color(safeColor(k, '#2a2010')), k.opacity(0.12), k.z(0)])
|
|
}
|
|
// Acacia trees with branches
|
|
for (const tx of [W * 0.1, W * 0.4, W * 0.72]) {
|
|
const trunkH = 40 + Math.random() * 20
|
|
k.add([k.rect(5, trunkH), k.pos(tx, GROUND_Y - trunkH), k.color(safeColor(k, '#2a1a08')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(15, 3), k.pos(tx - 10, GROUND_Y - trunkH * 0.7), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(1), k.rotate(-20)])
|
|
k.add([k.rect(12, 3), k.pos(tx + 5, GROUND_Y - trunkH * 0.6), k.color(safeColor(k, '#2a1a08')), k.opacity(0.3), k.z(1), k.rotate(15)])
|
|
k.add([k.rect(50, 5), k.pos(tx - 22, GROUND_Y - trunkH - 2), k.color(safeColor(k, '#2a3a10')), k.opacity(0.3), k.z(1)])
|
|
k.add([k.rect(40, 4), k.pos(tx - 17, GROUND_Y - trunkH - 7), k.color(safeColor(k, '#2a3a10')), k.opacity(0.25), k.z(1)])
|
|
k.add([k.rect(25, 3), k.pos(tx - 10, GROUND_Y - trunkH - 11), k.color(safeColor(k, '#223308')), k.opacity(0.2), k.z(1)])
|
|
}
|
|
// Setting sun
|
|
const sunX = W * 0.85, sunY = GROUND_Y * 0.22
|
|
k.add([k.circle(50), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#dd6622')), k.opacity(0.06), k.z(0)])
|
|
k.add([k.circle(35), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#dd6622')), k.opacity(0.12), k.z(0)])
|
|
const savSun = k.add([k.circle(22), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k, '#ffaa22')), k.opacity(0.3), k.z(0)])
|
|
savSun.onUpdate(() => { savSun.opacity = 0.25 + Math.sin(k.time() * 0.3) * 0.05 })
|
|
// Tall grass swaying
|
|
for (let i = 0; i < 20; i++) {
|
|
const gx = Math.random() * W
|
|
const grassH = 5 + Math.random() * 10
|
|
const grass = k.add([k.rect(1, grassH), k.pos(gx, GROUND_Y - grassH), k.color(safeColor(k, '#8a7830')), k.opacity(0.2), k.z(3), k.rotate(0)])
|
|
grass.onUpdate(() => { grass.angle = Math.sin(k.time() * 1.2 + i) * 5 })
|
|
}
|
|
// Elephant silhouette
|
|
const ex = W * 0.6, ey = GROUND_Y - 18
|
|
k.add([k.rect(18, 12), k.pos(ex, ey), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
|
|
k.add([k.rect(8, 8), k.pos(ex + 15, ey - 4), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
|
|
k.add([k.rect(3, 8), k.pos(ex + 2, ey + 12), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
|
|
k.add([k.rect(3, 8), k.pos(ex + 13, ey + 12), k.color(safeColor(k, '#1a1508')), k.opacity(0.15), k.z(0)])
|
|
// Birds in V formation
|
|
for (let i = 0; i < 5; i++) {
|
|
const birdX = W * 0.3 + i * 8
|
|
const birdY = 20 + Math.abs(i - 2) * 5
|
|
k.add([k.rect(4, 1), k.pos(birdX, birdY), k.color(safeColor(k, '#222222')), k.opacity(0.2), k.z(1), k.rotate(-12)])
|
|
k.add([k.rect(4, 1), k.pos(birdX + 3, birdY), k.color(safeColor(k, '#222222')), k.opacity(0.2), k.z(1), k.rotate(12)])
|
|
}
|
|
|
|
} else if (a === 'server_room') {
|
|
// Server rack rows with LEDs
|
|
for (let row = 0; row < 3; row++) {
|
|
for (let col = 0; col < 6; col++) {
|
|
const rx = 30 + col * (W / 6)
|
|
const ry = 20 + row * 55
|
|
const rw = W / 6 - 12
|
|
const rh = 46
|
|
k.add([k.rect(rw, rh), k.pos(rx, ry), k.color(safeColor(k, '#0a1a10')), k.opacity(0.4), k.z(1)])
|
|
k.add([k.rect(rw, 1), k.pos(rx, ry), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)])
|
|
k.add([k.rect(rw, 1), k.pos(rx, ry + rh), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)])
|
|
for (let s = 0; s < 5; s++) {
|
|
k.add([k.rect(rw - 4, 1), k.pos(rx + 2, ry + 10 + s * 7), k.color(safeColor(k, '#0a1008')), k.opacity(0.3), k.z(1)])
|
|
}
|
|
for (let led = 0; led < 4; led++) {
|
|
const ledEl = k.add([
|
|
k.rect(2, 2), k.pos(rx + 3 + led * 5, ry + 3),
|
|
k.color(safeColor(k, '#00ff88')), k.opacity(0.5), k.z(2),
|
|
])
|
|
ledEl.onUpdate(() => { ledEl.opacity = Math.random() > 0.04 ? 0.5 : 0.1 })
|
|
}
|
|
if (Math.random() < 0.5) {
|
|
const act = k.add([k.rect(2, 2), k.pos(rx + rw - 6, ry + 3), k.color(safeColor(k, '#ffaa00')), k.opacity(0.5), k.z(2)])
|
|
act.onUpdate(() => { act.opacity = Math.random() > 0.3 ? 0.5 : 0.15 })
|
|
}
|
|
}
|
|
}
|
|
// Overhead cable trays
|
|
for (let i = 0; i < 3; i++) {
|
|
k.add([k.rect(W * 0.25, 3), k.pos(W * (0.1 + i * 0.3), 5), k.color(safeColor(k, '#1a2a18')), k.opacity(0.3), k.z(1)])
|
|
for (let c = 0; c < 4; c++) {
|
|
k.add([k.rect(W * 0.25, 1), k.pos(W * (0.1 + i * 0.3), 7 + c * 2), k.color(safeColor(k, ['#004400', '#000044', '#440000', '#444400'][c])), k.opacity(0.15), k.z(1)])
|
|
}
|
|
}
|
|
// Cool air flow particles
|
|
for (let i = 0; i < 5; i++) {
|
|
const airflow = k.add([
|
|
k.circle(2), k.pos(Math.random() * W, GROUND_Y - 10),
|
|
k.color(safeColor(k, '#00ff88')), k.opacity(0.08), k.z(3),
|
|
])
|
|
airflow.onUpdate(() => {
|
|
airflow.pos.y -= 15 * k.dt()
|
|
if (airflow.pos.y < 10) { airflow.pos.y = GROUND_Y - 10; airflow.pos.x = Math.random() * W }
|
|
})
|
|
}
|
|
// Emergency lighting
|
|
const emLight = k.add([k.circle(4), k.pos(W * 0.5, 5), k.anchor('center'), k.color(safeColor(k, '#ff0000')), k.opacity(0.2), k.z(2)])
|
|
emLight.onUpdate(() => { emLight.opacity = 0.1 + Math.sin(k.time() * 1.5) * 0.1 })
|
|
|
|
} else if (a === 'paper_mill') {
|
|
// Industrial ceiling pipes
|
|
for (let i = 0; i < 5; i++) {
|
|
const py = 5 + i * 4
|
|
k.add([k.rect(W, 3), k.pos(0, py), k.color(safeColor(k, i % 2 === 0 ? '#2a2a20' : '#222218')), k.opacity(0.35), k.z(1)])
|
|
for (let j = 0; j < 4; j++) {
|
|
k.add([k.circle(3), k.pos(W * (0.2 + j * 0.2), py + 1), k.anchor('center'), k.color(safeColor(k, '#333328')), k.opacity(0.3), k.z(1)])
|
|
}
|
|
}
|
|
// Conveyor belt
|
|
k.add([k.rect(W * 0.6, 6), k.pos(W * 0.2, GROUND_Y - 15), k.color(safeColor(k, '#333328')), k.opacity(0.35), k.z(1)])
|
|
for (let i = 0; i < 8; i++) {
|
|
k.add([k.circle(3), k.pos(W * 0.22 + i * (W * 0.075), GROUND_Y - 12), k.anchor('center'), k.color(safeColor(k, '#444438')), k.opacity(0.3), k.z(1)])
|
|
}
|
|
// Moving items on belt
|
|
for (let i = 0; i < 3; i++) {
|
|
const item = k.add([
|
|
k.rect(10, 6), k.pos(W * 0.25 + i * 60, GROUND_Y - 21),
|
|
k.color(safeColor(k, '#f0e68c')), k.opacity(0.2), k.z(2),
|
|
])
|
|
item.onUpdate(() => {
|
|
item.pos.x += 15 * k.dt()
|
|
if (item.pos.x > W * 0.78) item.pos.x = W * 0.2
|
|
})
|
|
}
|
|
// Paper rolls stacked
|
|
for (let i = 0; i < 3; i++) {
|
|
for (let j = 0; j <= i; j++) {
|
|
const rx = W * 0.08 + j * 14 - i * 7
|
|
const ry = GROUND_Y - 12 - (2 - i) * 12
|
|
k.add([k.circle(6), k.pos(rx, ry), k.anchor('center'), k.color(safeColor(k, '#ddd8b0')), k.opacity(0.2), k.z(1)])
|
|
k.add([k.circle(2), k.pos(rx, ry), k.anchor('center'), k.color(safeColor(k, '#aaa880')), k.opacity(0.15), k.z(1)])
|
|
}
|
|
}
|
|
// Industrial gears (rotating)
|
|
for (const gx of [W * 0.88, W * 0.92]) {
|
|
const gy = GROUND_Y * 0.35
|
|
k.add([k.circle(10), k.pos(gx, gy), k.anchor('center'), k.color(safeColor(k, '#444438')), k.opacity(0.25), k.z(1)])
|
|
k.add([k.circle(4), k.pos(gx, gy), k.anchor('center'), k.color(safeColor(k, '#333328')), k.opacity(0.3), k.z(1)])
|
|
for (let t = 0; t < 8; t++) {
|
|
const tooth = k.add([
|
|
k.rect(4, 3), k.pos(gx, gy), k.anchor('center'),
|
|
k.color(safeColor(k, '#444438')), k.opacity(0.25), k.z(1),
|
|
k.rotate(t * 45),
|
|
])
|
|
tooth.onUpdate(() => { tooth.angle = t * 45 + k.time() * (gx === W * 0.88 ? 20 : -25) })
|
|
}
|
|
}
|
|
// Steam from pipes
|
|
for (let i = 0; i < 4; i++) {
|
|
const steam = k.add([
|
|
k.circle(6 + Math.random() * 8), k.pos(W * (0.2 + i * 0.2), 25),
|
|
k.color(safeColor(k, '#ccccbb')), k.opacity(0.06), k.z(2),
|
|
])
|
|
const bsx = steam.pos.x
|
|
steam.onUpdate(() => {
|
|
steam.pos.y += 4 * k.dt()
|
|
steam.pos.x = bsx + Math.sin(k.time() + i) * 5
|
|
steam.opacity *= 0.998
|
|
if (steam.pos.y > GROUND_Y * 0.4) { steam.pos.y = 25; steam.opacity = 0.06 }
|
|
})
|
|
}
|
|
// Sawdust particles
|
|
for (let i = 0; i < 8; i++) {
|
|
const dust = k.add([
|
|
k.circle(1), k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k, '#f0e68c')), k.opacity(0.12), k.z(3),
|
|
])
|
|
const dx = dust.pos.x, dy = dust.pos.y
|
|
dust.onUpdate(() => {
|
|
dust.pos.x = dx + Math.sin(k.time() * 0.8 + i) * 10
|
|
dust.pos.y = dy + Math.cos(k.time() * 0.5 + i) * 8
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
k.scene('fight', () => {
|
|
// === SKY / BACKGROUND ATMOSPHERE ===
|
|
// Vertical gradient backdrop — 6 bands from dark ceiling to ground horizon
|
|
const bgBase = theme.bg
|
|
for (let band = 0; band < 6; band++) {
|
|
const bandH = GROUND_Y / 6
|
|
const brighten = band * 3
|
|
k.add([
|
|
k.rect(W, bandH + 1), k.pos(0, band * bandH),
|
|
k.color(safeColor(k,bgBase)),
|
|
k.opacity(1 - band * 0.04),
|
|
])
|
|
}
|
|
// Subtle horizon glow — wide soft band just above ground
|
|
k.add([
|
|
k.rect(W, 20), k.pos(0, GROUND_Y - 20),
|
|
k.color(safeColor(k,theme.accent)), k.opacity(0.06),
|
|
])
|
|
|
|
// === GROUND ===
|
|
// Main ground fill
|
|
k.add([k.rect(W, H - GROUND_Y + 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.ground))])
|
|
// Bright edge line at the surface
|
|
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.accent)), k.opacity(0.7)])
|
|
// Secondary highlight
|
|
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3)])
|
|
// Ground gradient — fades deeper
|
|
for (let i = 1; i <= 12; i++) {
|
|
k.add([k.rect(W, 2), k.pos(0, GROUND_Y + 3 + i * 6), k.color(safeColor(k,theme.accent)), k.opacity(0.1 - i * 0.007)])
|
|
}
|
|
// Perspective grid on the floor — converging lines for 3D depth
|
|
const vanishX = W / 2
|
|
const vanishY = GROUND_Y * 0.6
|
|
for (let i = 0; i <= 24; i++) {
|
|
const x = i * (W / 24)
|
|
// Lines from floor edge pointing toward vanishing point
|
|
const dx = x - vanishX
|
|
const topX = vanishX + dx * 0.3
|
|
k.add([
|
|
k.rect(1, GROUND_Y * 0.4 + 10), k.pos(x, GROUND_Y),
|
|
k.color(safeColor(k,theme.accent)), k.opacity(0.05),
|
|
])
|
|
}
|
|
// Horizontal depth lines on ground
|
|
for (let i = 0; i < 6; i++) {
|
|
const gy = GROUND_Y + 8 + i * i * 3
|
|
if (gy < H) {
|
|
k.add([k.rect(W, 1), k.pos(0, gy), k.color(safeColor(k,theme.accent)), k.opacity(0.08 - i * 0.012)])
|
|
}
|
|
}
|
|
|
|
// === SIDE PILLARS / FRAME ===
|
|
// Subtle dark pillars on edges for framing
|
|
k.add([k.rect(8, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
|
|
k.add([k.rect(8, GROUND_Y), k.pos(W - 8, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)])
|
|
// Accent trim on pillars
|
|
k.add([k.rect(1, GROUND_Y), k.pos(8, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
|
|
k.add([k.rect(1, GROUND_Y), k.pos(W - 9, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)])
|
|
|
|
// === AMBIENT PARTICLES ===
|
|
// Floating dust/embers in the air (subtle, always present)
|
|
for (let i = 0; i < 18; i++) {
|
|
const particle = k.add([
|
|
k.circle(0.5 + Math.random() * 1.5),
|
|
k.pos(Math.random() * W, Math.random() * GROUND_Y),
|
|
k.color(safeColor(k,theme.accent)),
|
|
k.opacity(0.08 + Math.random() * 0.12),
|
|
k.z(2),
|
|
])
|
|
const baseX = particle.pos.x
|
|
const baseY = particle.pos.y
|
|
const driftSpeed = 0.1 + Math.random() * 0.3
|
|
const driftAmt = 8 + Math.random() * 15
|
|
particle.onUpdate(() => {
|
|
particle.pos.x = baseX + Math.sin(k.time() * driftSpeed + i * 1.3) * driftAmt
|
|
particle.pos.y = baseY + Math.cos(k.time() * driftSpeed * 0.7 + i) * (driftAmt * 0.5)
|
|
particle.opacity = 0.06 + Math.sin(k.time() * 0.8 + i * 0.9) * 0.06
|
|
})
|
|
}
|
|
|
|
// Arena-specific decorations
|
|
drawArenaDecor()
|
|
|
|
// Umpire chair (center-back, behind fighters)
|
|
const CHAIR_X = W / 2
|
|
const CHAIR_SEAT_Y = GROUND_Y * (0.38 + (1 - SF) * 0.2)
|
|
const cs = SF // chair scale
|
|
k.add([k.rect(4 * cs, GROUND_Y - CHAIR_SEAT_Y + 15 * cs), k.pos(CHAIR_X - 22 * cs, CHAIR_SEAT_Y - 8 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
|
|
k.add([k.rect(4 * cs, GROUND_Y - CHAIR_SEAT_Y + 15 * cs), k.pos(CHAIR_X + 18 * cs, CHAIR_SEAT_Y - 8 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
|
|
for (let i = 0; i < 6; i++) {
|
|
const rungY = CHAIR_SEAT_Y + 15 * cs + i * ((GROUND_Y - CHAIR_SEAT_Y - 15 * cs) / 6)
|
|
k.add([k.rect(44 * cs, 3), k.pos(CHAIR_X - 22 * cs, rungY), k.color(safeColor(k,'#443322')), k.opacity(0.6), k.z(3)])
|
|
}
|
|
k.add([k.rect(54 * cs, 5), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 2), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)])
|
|
k.add([k.rect(54 * cs, 1), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3), k.z(3)])
|
|
k.add([k.rect(48 * cs, 4), k.pos(CHAIR_X - 24 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)])
|
|
k.add([k.rect(3, 12 * cs), k.pos(CHAIR_X - 27 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
|
|
k.add([k.rect(3, 12 * cs), k.pos(CHAIR_X + 24 * cs, CHAIR_SEAT_Y - 10 * cs), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)])
|
|
k.add([
|
|
k.sprite('judge', { anim: 'idle' }),
|
|
k.pos(CHAIR_X, CHAIR_SEAT_Y),
|
|
k.anchor('bot'),
|
|
k.scale(1.3 * SF),
|
|
k.z(4),
|
|
k.opacity(0.9),
|
|
'judge',
|
|
])
|
|
|
|
const scaleA = (1.8 + botA.tier * 0.4) * SF
|
|
k.add([k.sprite('botA', { anim: 'idle' }), k.pos(-100, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), k.opacity(0), 'fighterA'])
|
|
|
|
const scaleB = (1.8 + botB.tier * 0.4) * SF
|
|
k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W + 100, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), k.opacity(0), 'fighterB'])
|
|
|
|
config.onReady?.()
|
|
})
|
|
|
|
k.go('fight')
|
|
|
|
let comboA = 0
|
|
let comboB = 0
|
|
|
|
// === CHOREOGRAPHY FUNCTIONS ===
|
|
// Each returns a Promise and handles all movement + animation
|
|
|
|
async function dashPunch(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 50
|
|
// Fast sprint to contact
|
|
await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('attack')
|
|
sfxPunch()
|
|
await k.wait(0.12)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 12 : 5)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 12 : 5, '#ffcc00')
|
|
if (isCritical) { screenFlash('#ffffff'); sfxCritical(); await impactFreeze() }
|
|
spawnAfterimages(atk, 2)
|
|
// Push defender
|
|
const push = dir * (isCritical ? 100 : 35)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
// Return
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
async function aerialSlam(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const origAY = atk.pos.y
|
|
const contactX = origDX - dir * 20
|
|
// Jump way up
|
|
await k.tween(atk.pos.y, origAY - 220, 0.18, (v) => { atk.pos.y = v }, k.easings.easeOutQuad)
|
|
atk.play('kick')
|
|
sfxKick()
|
|
// Arc down onto opponent
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, contactX, 0.22, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(atk.pos.y, origAY, 0.22, (v) => { atk.pos.y = v }, k.easings.easeInQuad),
|
|
])
|
|
// SLAM
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 20 : 10)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 15, theme.accent)
|
|
spawnShockwave(def.pos.x, GROUND_Y, theme.accent)
|
|
if (isCritical) { screenFlash('#ff2d7b'); sfxExplosion(); await impactFreeze(0.1) } else { sfxPunch() }
|
|
spawnAfterimages(atk, 3)
|
|
const push = dir * (isCritical ? 120 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(atk.pos.y, origAY, 0.15, (v) => { atk.pos.y = v }, k.easings.easeOutBounce),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
async function flyingKick(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const origAY = atk.pos.y
|
|
const contactX = origDX - dir * 30
|
|
// Small jump + fly horizontally
|
|
atk.play('kick')
|
|
sfxKick()
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, contactX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(atk.pos.y, origAY - 80, 0.1, (v) => { atk.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(atk.pos.y, origAY, 0.1, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 14 : 6)
|
|
spawnSparks(def.pos.x, def.pos.y - 40, 8, '#ff6600')
|
|
if (isCritical) { sfxCritical(); screenFlash('#ff6600'); await impactFreeze() } else { sfxPunch() }
|
|
spawnAfterimages(atk, 3)
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
// Bounce back
|
|
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.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
async function dashThrough(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const behindX = origDX + dir * 80
|
|
atk.play('attack')
|
|
sfxSpecial()
|
|
// Blazing fast dash through opponent
|
|
await k.tween(atk.pos.x, behindX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
// Hit happens mid-pass
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 7)
|
|
spawnSparks(origDX, def.pos.y - 30, 10, '#00f0ff')
|
|
spawnAfterimages(atk, 3)
|
|
if (isCritical) { screenFlash('#00f0ff'); await impactFreeze() }
|
|
// Dramatic pause behind opponent
|
|
await k.wait(0.3)
|
|
// Zip back to start
|
|
atk.play('idle')
|
|
await k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.1)
|
|
}
|
|
|
|
async function uppercut(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 40
|
|
const origDY = def.pos.y
|
|
// Run to contact
|
|
await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.1)
|
|
// Launch defender into the air!
|
|
def.play('knockback')
|
|
k.shake(isCritical ? 18 : 8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 12, '#ffe14d')
|
|
if (isCritical) { screenFlash('#ffe14d'); sfxExplosion(); await impactFreeze(0.1) }
|
|
const launchHeight = isCritical ? 250 : 160
|
|
await k.tween(def.pos.y, origDY - launchHeight, 0.25, (v) => { def.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.15)
|
|
// Defender crashes back down
|
|
await k.tween(def.pos.y, origDY, 0.3, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d')
|
|
k.shake(5)
|
|
sfxPunch()
|
|
await k.wait(0.2)
|
|
// Return
|
|
await k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
}
|
|
|
|
async function multiHit(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 45
|
|
// Sprint to contact
|
|
await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
const hitCount = isCritical ? 5 : 3
|
|
for (let i = 0; i < hitCount; i++) {
|
|
atk.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxPunch()
|
|
await k.wait(0.08)
|
|
def.play('hit')
|
|
k.shake(3 + i)
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 - Math.random() * 40, 4, '#ff2d7b')
|
|
// Jitter defender
|
|
def.pos.x += dir * 8
|
|
await k.wait(0.08)
|
|
}
|
|
if (isCritical) { screenFlash('#ff2d7b'); sfxCritical() }
|
|
// Final big push
|
|
def.play('knockback')
|
|
k.shake(12)
|
|
const push = dir * (isCritical ? 130 : 60)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
async function projectile(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.25)
|
|
// Fire projectile
|
|
const projY = atk.pos.y - 40
|
|
const color = isCritical ? '#ff2d7b' : theme.accent
|
|
const size = isCritical ? 12 : 8
|
|
await spawnProjectile(atk.pos.x + dir * 30, projY, def.pos.x, def.pos.y - 40, color, size)
|
|
// Impact
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 6)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 20 : 8, color)
|
|
if (isCritical) { screenFlash(color); sfxExplosion() }
|
|
const push = dir * (isCritical ? 80 : 30)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function jetpackDive(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const origAY = atk.pos.y
|
|
sfxJetpack()
|
|
// Jetpack blast off - fly way up off screen
|
|
const exhaustInterval = spawnExhaust(atk.pos.x, atk.pos.y, 0.8)
|
|
await k.tween(atk.pos.y, -50, 0.3, (v) => {
|
|
atk.pos.y = v
|
|
// Update exhaust position tracking would need the closure to capture, but exhaust spawns from initial pos
|
|
}, k.easings.easeInQuad)
|
|
clearInterval(exhaustInterval)
|
|
// Reposition above opponent
|
|
atk.pos.x = origDX
|
|
await k.wait(0.15)
|
|
// Dive bomb!
|
|
atk.play('kick')
|
|
sfxJetpack()
|
|
const exhaust2 = spawnExhaust(atk.pos.x, atk.pos.y, 0.3)
|
|
await k.tween(atk.pos.y, origAY, 0.2, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
clearInterval(exhaust2)
|
|
// IMPACT
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 25 : 12)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 20, '#ff6600')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
screenFlash(isCritical ? '#ff2d2d' : '#ff6600', 0.15)
|
|
sfxExplosion()
|
|
// Push defender far
|
|
const push = dir * (isCritical ? 150 : 80)
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
// Return
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.4, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
async function gunBurst(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
// Spawn visible pixel gun prop
|
|
const gunX = atk.pos.x + dir * 15
|
|
const gunY = atk.pos.y - 40
|
|
const gunDef = PROPS.gun
|
|
const gunObjs = spawnWeaponProp('gun', gunX, gunY, dir < 0, 18)
|
|
await k.wait(0.15)
|
|
const bulletCount = isCritical ? 8 : 4
|
|
const muzzleX = gunX + dir * 30
|
|
// Fire bullets in rapid succession
|
|
for (let i = 0; i < bulletCount; i++) {
|
|
sfxGunshot()
|
|
// Muzzle flash
|
|
const flash = k.add([k.circle(5), k.pos(muzzleX, gunY), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')])
|
|
setTimeout(() => { if (flash.exists()) flash.destroy() }, 35)
|
|
const targetY = def.pos.y - 20 - Math.random() * 50
|
|
spawnBullet(muzzleX, gunY, def.pos.x, targetY)
|
|
// Recoil: bounce gun back
|
|
if (gunDef) moveProp(gunObjs, gunDef, gunX - dir * 4, gunY, dir < 0)
|
|
atk.pos.x -= dir * 3
|
|
await k.wait(0.04)
|
|
if (gunDef) moveProp(gunObjs, gunDef, gunX, gunY, dir < 0)
|
|
atk.pos.x += dir * 3
|
|
sfxBulletHit()
|
|
def.opacity = 0.4
|
|
await k.wait(0.02)
|
|
def.opacity = 1
|
|
}
|
|
// Remove gun
|
|
destroyProp(gunObjs)
|
|
spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 5 : 3)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 8)
|
|
if (isCritical) { screenFlash('#ffee00'); sfxExplosion() }
|
|
const push = dir * (isCritical ? 100 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function groundPound(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const origAY = atk.pos.y
|
|
// Jump super high off screen
|
|
sfxJetpack()
|
|
await k.tween(atk.pos.y, -100, 0.25, (v) => { atk.pos.y = v }, k.easings.easeOutQuad)
|
|
// Move to center of stage
|
|
const slamX = (origAX + origDX) / 2
|
|
atk.pos.x = slamX
|
|
await k.wait(0.2)
|
|
// SLAM DOWN
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.tween(atk.pos.y, origAY, 0.12, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
// Massive shockwave
|
|
k.shake(isCritical ? 30 : 18)
|
|
screenFlash(isCritical ? '#ff2d2d' : '#ffe14d', 0.2)
|
|
sfxExplosion()
|
|
spawnShockwave(slamX, GROUND_Y, theme.accent)
|
|
spawnSparks(slamX, GROUND_Y - 10, 25, theme.accent)
|
|
// Defender gets launched
|
|
const origDY = def.pos.y
|
|
def.play('knockback')
|
|
const push = dir * (isCritical ? 130 : 70)
|
|
const launchH = isCritical ? 180 : 100
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.y, origDY - launchH, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(def.pos.y, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
k.shake(5)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d')
|
|
await k.wait(0.3)
|
|
// Return
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.4, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
async function teleportStrike(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Fade out
|
|
sfxSpecial()
|
|
spawnSparks(atk.pos.x, atk.pos.y - 30, 8, '#b83dff')
|
|
await k.tween(1, 0, 0.15, (v) => { atk.opacity = v })
|
|
// Teleport behind opponent
|
|
atk.pos.x = origDX + dir * 60
|
|
await k.wait(0.1)
|
|
// Fade in behind
|
|
spawnSparks(atk.pos.x, atk.pos.y - 30, 8, '#b83dff')
|
|
await k.tween(0, 1, 0.1, (v) => { atk.opacity = v })
|
|
// Strike from behind!
|
|
atk.play('attack')
|
|
sfxPunch()
|
|
await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 12, '#b83dff')
|
|
if (isCritical) { screenFlash('#b83dff'); sfxCritical() }
|
|
// Push defender forward (reversed since we're behind)
|
|
const push = -dir * (isCritical ? 80 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
// Teleport back home
|
|
spawnSparks(atk.pos.x, atk.pos.y - 30, 6, '#b83dff')
|
|
await k.tween(1, 0, 0.1, (v) => { atk.opacity = v })
|
|
atk.pos.x = origAX
|
|
await k.tween(0, 1, 0.1, (v) => { atk.opacity = v })
|
|
spawnSparks(origAX, atk.pos.y - 30, 6, '#b83dff')
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function fullScreenDash(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Back up to wall
|
|
const wallX = dir === 1 ? 20 : W - 20
|
|
await k.tween(atk.pos.x, wallX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.1)
|
|
// FULL SPEED DASH across entire screen!
|
|
atk.play('kick')
|
|
sfxSpecial()
|
|
const targetX = dir === 1 ? W - 20 : 20
|
|
// Speed lines
|
|
for (let i = 0; i < 8; i++) {
|
|
const lineY = Math.random() * H * 0.7
|
|
const line = k.add([
|
|
k.rect(W, 2),
|
|
k.pos(0, lineY),
|
|
k.color(safeColor(k,theme.accent)),
|
|
k.opacity(0.3),
|
|
k.z(12),
|
|
])
|
|
setTimeout(() => { if (line.exists()) line.destroy() }, 300)
|
|
}
|
|
await k.tween(atk.pos.x, targetX, 0.18, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad)
|
|
// Hit happens when passing through defender
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 22 : 10)
|
|
spawnSparks(origDX, def.pos.y - 30, 15, '#00f0ff')
|
|
screenFlash(isCritical ? '#ffffff' : '#00f0ff', 0.12)
|
|
if (isCritical) sfxExplosion()
|
|
else sfxKick()
|
|
const push = dir * (isCritical ? 100 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
// Return from far side
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// === THEMED CHOREOGRAPHIES (challenge-specific props) ===
|
|
|
|
// Helper: spawn a flying prop (rect or circle) from A to B
|
|
function spawnProp(
|
|
fromX: number, fromY: number, toX: number, toY: number,
|
|
w: number, h: number, color: string, speed: number = 600, isCircle: boolean = false,
|
|
): Promise<void> {
|
|
return new Promise(resolve => {
|
|
const prop = k.add([
|
|
isCircle ? k.circle(w / 2) : k.rect(w, h),
|
|
k.pos(fromX, fromY),
|
|
k.color(safeColor(k,color)),
|
|
k.opacity(1),
|
|
k.z(16),
|
|
k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI),
|
|
])
|
|
const dx = toX - fromX
|
|
const dy = toY - fromY
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
const dur = dist / speed
|
|
k.tween(0, 1, dur, (t) => {
|
|
prop.pos.x = fromX + dx * t
|
|
prop.pos.y = fromY + dy * t
|
|
}, k.easings.linear).then(() => {
|
|
prop.destroy()
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
// Helper: spawn multiple flying objects in a stream
|
|
async function spawnBarrage(
|
|
fromX: number, fromY: number, toX: number, toY: number,
|
|
count: number, color: string, size: number, spread: number, delay: number, isCircle: boolean = false,
|
|
) {
|
|
for (let i = 0; i < count; i++) {
|
|
const offY = (Math.random() - 0.5) * spread
|
|
const offX = (Math.random() - 0.5) * spread * 0.3
|
|
spawnProp(fromX + offX, fromY + offY, toX + offX, toY + offY, size, size, color, 500 + Math.random() * 300, isCircle)
|
|
await k.wait(delay)
|
|
}
|
|
}
|
|
|
|
// CODE GOLF: swing a giant golf club, golf balls fly at opponent
|
|
async function golfClubSmash(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 60
|
|
await k.tween(atk.pos.x, contactX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
// Spawn golf club (long thin rect)
|
|
const club = k.add([
|
|
k.rect(8, 60),
|
|
k.pos(atk.pos.x + dir * 20, atk.pos.y - 60),
|
|
k.color(safeColor(k,'#888888')),
|
|
k.anchor('bot'),
|
|
k.z(18),
|
|
k.rotate(dir === 1 ? -45 : 45),
|
|
])
|
|
// Club head (wider rect at end)
|
|
const clubHead = k.add([
|
|
k.rect(18, 12),
|
|
k.pos(club.pos.x + dir * 15, club.pos.y - 55),
|
|
k.color(safeColor(k,'#cccccc')),
|
|
k.z(18),
|
|
])
|
|
atk.play('attack')
|
|
sfxSpecial()
|
|
// Swing the club
|
|
await k.tween(dir === 1 ? -45 : 45, dir === 1 ? 90 : -90, 0.15, (v) => { club.angle = v }, k.easings.easeInQuad)
|
|
sfxBonk()
|
|
// Club hits - launch golf balls!
|
|
club.destroy()
|
|
clubHead.destroy()
|
|
const ballCount = isCritical ? 6 : 3
|
|
for (let i = 0; i < ballCount; i++) {
|
|
const ballY = def.pos.y - 20 - Math.random() * 40
|
|
const ball = k.add([
|
|
k.circle(5),
|
|
k.pos(atk.pos.x + dir * 30, atk.pos.y - 40),
|
|
k.color(safeColor(k,'#ffffff')),
|
|
k.z(16),
|
|
])
|
|
sfxPunch()
|
|
const targetX = def.pos.x + (Math.random() - 0.5) * 30
|
|
k.tween(0, 1, 0.12, (t) => {
|
|
ball.pos.x = (atk.pos.x + dir * 30) + (targetX - atk.pos.x - dir * 30) * t
|
|
ball.pos.y = (atk.pos.y - 40) + (ballY - atk.pos.y + 40) * t - Math.sin(t * Math.PI) * 60
|
|
}).then(() => {
|
|
ball.destroy()
|
|
spawnSparks(targetX, ballY, 4, '#ffffff')
|
|
})
|
|
await k.wait(0.06)
|
|
}
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 8)
|
|
if (isCritical) screenFlash('#39ff14')
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 10, '#39ff14')
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// ROAST BATTLE: fire breath — stream of fire from attacker's face
|
|
async function fireBreath(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 80
|
|
await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Fire stream!
|
|
const fireX = atk.pos.x + dir * 25
|
|
const fireY = atk.pos.y - 45
|
|
const fireCount = isCritical ? 40 : 20
|
|
for (let i = 0; i < fireCount; i++) {
|
|
const p = k.add([
|
|
k.circle(4 + Math.random() * 6),
|
|
k.pos(fireX, fireY + (Math.random() - 0.5) * 15),
|
|
k.color(safeColor(k,Math.random() > 0.3 ? '#ff6600' : Math.random() > 0.5 ? '#ffcc00' : '#ff2d2d')),
|
|
k.opacity(0.9),
|
|
k.z(16),
|
|
])
|
|
const speed = 400 + Math.random() * 400
|
|
const vy = (Math.random() - 0.5) * 100
|
|
p.onUpdate(() => {
|
|
p.pos.x += dir * speed * k.dt()
|
|
p.pos.y += vy * k.dt()
|
|
p.opacity -= 2 * k.dt()
|
|
if (p.opacity <= 0 || Math.abs(p.pos.x - fireX) > Math.abs(def.pos.x - fireX) + 30) p.destroy()
|
|
})
|
|
if (i % 5 === 0) {
|
|
def.opacity = 0.5
|
|
setTimeout(() => { if (def.exists()) def.opacity = 1 }, 50)
|
|
}
|
|
await k.wait(0.02)
|
|
}
|
|
await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 20 : 8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ff6600')
|
|
if (isCritical) { screenFlash('#ff6600'); sfxExplosion() }
|
|
// Defender on fire briefly
|
|
const fireTimer = setInterval(() => {
|
|
if (!def.exists()) { clearInterval(fireTimer); return }
|
|
k.add([
|
|
k.circle(3),
|
|
k.pos(def.pos.x + (Math.random() - 0.5) * 25, def.pos.y - Math.random() * 60),
|
|
k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')),
|
|
k.opacity(0.7),
|
|
k.z(11),
|
|
]).onUpdate(function(this: any) { this.pos.y -= 80 * k.dt(); this.opacity -= 2 * k.dt(); if (this.opacity <= 0) this.destroy() })
|
|
}, 40)
|
|
const push = dir * (isCritical ? 110 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.6)
|
|
clearInterval(fireTimer)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// RIDDLE: question mark barrage rains from the sky
|
|
async function riddleBarrage(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
sfxZap()
|
|
await k.wait(0.2)
|
|
// Rain question marks from above
|
|
const qCount = isCritical ? 12 : 6
|
|
for (let i = 0; i < qCount; i++) {
|
|
const qx = def.pos.x + (Math.random() - 0.5) * 100
|
|
const qy = -20
|
|
const q = k.add([
|
|
k.circle(8),
|
|
k.pos(qx, qy),
|
|
k.color(safeColor(k,'#b83dff')),
|
|
k.opacity(0.9),
|
|
k.z(16),
|
|
])
|
|
// Inner dot to make it look like "?"
|
|
const dot = k.add([
|
|
k.circle(3),
|
|
k.pos(qx, qy + 12),
|
|
k.color(safeColor(k,'#ffffff')),
|
|
k.opacity(0.9),
|
|
k.z(17),
|
|
])
|
|
k.tween(qy, def.pos.y - 20, 0.2 + Math.random() * 0.1, (v) => { q.pos.y = v; dot.pos.y = v + 12 }, k.easings.easeInQuad).then(() => {
|
|
q.destroy(); dot.destroy()
|
|
spawnSparks(qx, def.pos.y - 20, 3, '#b83dff')
|
|
sfxBonk()
|
|
k.shake(2)
|
|
})
|
|
await k.wait(0.06)
|
|
}
|
|
await k.wait(0.15)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 7)
|
|
if (isCritical) { screenFlash('#b83dff'); sfxExplosion() }
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// HALLUCINATION CHECK: clone confusion — multiple fake attackers appear
|
|
async function cloneStrike(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
sfxZap()
|
|
// Spawn 3-5 "ghost" clones that zip around
|
|
const cloneCount = isCritical ? 5 : 3
|
|
const clones: any[] = []
|
|
for (let i = 0; i < cloneCount; i++) {
|
|
const clone = k.add([
|
|
k.rect(20, 35),
|
|
k.pos(atk.pos.x, atk.pos.y - 25),
|
|
k.color(safeColor(k,theme.accent)),
|
|
k.opacity(0.3),
|
|
k.z(9),
|
|
])
|
|
clones.push(clone)
|
|
// Each clone zips to a random position
|
|
const rx = origDX + (Math.random() - 0.5) * 200
|
|
const ry = GROUND_Y - 6 - Math.random() * 80
|
|
k.tween(0, 1, 0.2, (t) => {
|
|
clone.pos.x = atk.pos.x + (rx - atk.pos.x) * t
|
|
clone.pos.y = (atk.pos.y - 25) + (ry - atk.pos.y + 25) * t
|
|
})
|
|
}
|
|
await k.wait(0.25)
|
|
// Defender is confused — screen warps
|
|
screenFlash('#b83dff', 0.1)
|
|
await k.wait(0.15)
|
|
// Real attacker teleports and strikes
|
|
spawnSparks(atk.pos.x, atk.pos.y - 30, 6, theme.accent)
|
|
await k.tween(1, 0, 0.08, (v) => { atk.opacity = v })
|
|
atk.pos.x = origDX - dir * 40
|
|
await k.tween(0, 1, 0.08, (v) => { atk.opacity = v })
|
|
atk.play('attack')
|
|
sfxPunch()
|
|
await k.wait(0.1)
|
|
// Destroy clones
|
|
clones.forEach(c => { if (c.exists()) { spawnSparks(c.pos.x, c.pos.y, 3, theme.accent); c.destroy() } })
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 16 : 7)
|
|
if (isCritical) { screenFlash('#ffffff'); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 10, theme.accent)
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// TOKEN ECONOMY: coin shower — coins rain down on opponent
|
|
async function coinShower(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
sfxBoing()
|
|
await k.wait(0.15)
|
|
// Coins rain from above
|
|
const coinCount = isCritical ? 20 : 10
|
|
for (let i = 0; i < coinCount; i++) {
|
|
const cx = def.pos.x + (Math.random() - 0.5) * 80
|
|
const coin = k.add([
|
|
k.circle(5),
|
|
k.pos(cx, -10),
|
|
k.color(safeColor(k,Math.random() > 0.3 ? '#ffd700' : '#ffb000')),
|
|
k.opacity(1),
|
|
k.z(16),
|
|
])
|
|
const targetY = def.pos.y - 10 - Math.random() * 50
|
|
k.tween(coin.pos.y, targetY, 0.15 + Math.random() * 0.1, (v) => { coin.pos.y = v }, k.easings.easeInQuad).then(() => {
|
|
coin.destroy()
|
|
spawnSparks(cx, targetY, 2, '#ffd700')
|
|
sfxBulletHit()
|
|
})
|
|
await k.wait(0.03)
|
|
}
|
|
await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 14 : 6)
|
|
if (isCritical) { screenFlash('#ffd700'); sfxExplosion() }
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// CREATIVE WRITING: giant pen stab + ink splatter
|
|
async function penStab(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Spawn a giant pen/quill
|
|
const penX = atk.pos.x + dir * 15
|
|
const penY = atk.pos.y - 70
|
|
const pen = k.add([
|
|
k.rect(6, 80),
|
|
k.pos(penX, penY),
|
|
k.color(safeColor(k,'#4488ff')),
|
|
k.anchor('bot'),
|
|
k.z(18),
|
|
k.rotate(dir === 1 ? -30 : 30),
|
|
])
|
|
// Pen tip
|
|
const tip = k.add([
|
|
k.rect(4, 12),
|
|
k.pos(penX + dir * 5, penY - 68),
|
|
k.color(safeColor(k,'#333333')),
|
|
k.z(18),
|
|
])
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.15)
|
|
// Thrust pen forward at opponent
|
|
const targetX = origDX - dir * 20
|
|
await Promise.all([
|
|
k.tween(pen.pos.x, targetX, 0.12, (v) => { pen.pos.x = v; tip.pos.x = v + dir * 5 }, k.easings.easeInQuad),
|
|
k.tween(pen.angle, dir === 1 ? 80 : -80, 0.12, (v) => { pen.angle = v }, k.easings.easeInQuad),
|
|
k.tween(atk.pos.x, targetX - dir * 30, 0.12, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
])
|
|
sfxPunch()
|
|
pen.destroy()
|
|
tip.destroy()
|
|
// INK SPLATTER!
|
|
const inkColors = ['#1a1a3a', '#2a2a5a', '#0a0a2a', '#4488ff']
|
|
for (let i = 0; i < (isCritical ? 20 : 10); i++) {
|
|
const splat = k.add([
|
|
k.circle(4 + Math.random() * 10),
|
|
k.pos(def.pos.x + (Math.random() - 0.5) * 60, def.pos.y - Math.random() * 70),
|
|
k.color(safeColor(k,inkColors[Math.floor(Math.random() * inkColors.length)])),
|
|
k.opacity(0.8),
|
|
k.z(9),
|
|
])
|
|
setTimeout(() => {
|
|
k.tween(0.8, 0, 1.5, (v) => { if (splat.exists()) splat.opacity = v }).then(() => { if (splat.exists()) splat.destroy() })
|
|
}, 800)
|
|
}
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 16 : 7)
|
|
if (isCritical) screenFlash('#4488ff')
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// MATH BLITZ: number projectiles fly at opponent
|
|
async function mathAttack(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.15)
|
|
// Fire number "projectiles" (colored circles representing digits)
|
|
const numCount = isCritical ? 10 : 5
|
|
const colors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#39ff14', '#b83dff']
|
|
for (let i = 0; i < numCount; i++) {
|
|
const size = 6 + Math.random() * 8
|
|
const fromY = atk.pos.y - 30 - Math.random() * 30
|
|
const toY = def.pos.y - 10 - Math.random() * 50
|
|
const num = k.add([
|
|
k.circle(size),
|
|
k.pos(atk.pos.x + dir * 25, fromY),
|
|
k.color(safeColor(k,colors[i % colors.length])),
|
|
k.opacity(1),
|
|
k.z(16),
|
|
])
|
|
const dur = 0.1 + Math.random() * 0.05
|
|
sfxGunshot()
|
|
k.tween(0, 1, dur, (t) => {
|
|
num.pos.x = (atk.pos.x + dir * 25) + (def.pos.x - atk.pos.x - dir * 25) * t
|
|
num.pos.y = fromY + (toY - fromY) * t
|
|
}).then(() => {
|
|
num.destroy()
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 20, toY, 3, colors[i % colors.length])
|
|
})
|
|
await k.wait(0.04)
|
|
}
|
|
await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 7)
|
|
if (isCritical) { screenFlash('#ffe14d'); sfxExplosion() }
|
|
const push = dir * (isCritical ? 95 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// TRAP CARD: card shuriken + trap springs
|
|
async function trapCardAttack(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.1)
|
|
// Throw spinning cards
|
|
const cardCount = isCritical ? 5 : 3
|
|
for (let i = 0; i < cardCount; i++) {
|
|
const card = k.add([
|
|
k.rect(12, 18),
|
|
k.pos(atk.pos.x + dir * 20, atk.pos.y - 40 + (Math.random() - 0.5) * 20),
|
|
k.color(safeColor(k,i === 0 ? '#ff2d7b' : '#ffffff')),
|
|
k.anchor('center'),
|
|
k.z(16),
|
|
k.rotate(0),
|
|
])
|
|
const fromX = card.pos.x
|
|
const fromY = card.pos.y
|
|
const toX = def.pos.x
|
|
const toY = def.pos.y - 30 + (Math.random() - 0.5) * 30
|
|
sfxGunshot()
|
|
k.tween(0, 1, 0.12, (t) => {
|
|
card.pos.x = fromX + (toX - fromX) * t
|
|
card.pos.y = fromY + (toY - fromY) * t
|
|
card.angle = t * 720 // Spin
|
|
}).then(() => {
|
|
card.destroy()
|
|
spawnSparks(toX, toY, 5, '#ff2d7b')
|
|
sfxBulletHit()
|
|
})
|
|
await k.wait(0.08)
|
|
}
|
|
await k.wait(0.1)
|
|
// TRAP SPRINGS! A bear trap appears under defender
|
|
const trap = k.add([
|
|
k.rect(30, 8),
|
|
k.pos(def.pos.x - 15, GROUND_Y - 8),
|
|
k.color(safeColor(k,'#888888')),
|
|
k.z(8),
|
|
])
|
|
const jaw1 = k.add([
|
|
k.rect(30, 4),
|
|
k.pos(def.pos.x - 15, GROUND_Y - 16),
|
|
k.color(safeColor(k,'#aaaaaa')),
|
|
k.z(8),
|
|
])
|
|
sfxBonk()
|
|
k.shake(3)
|
|
await k.wait(0.05)
|
|
// Snap! Jaws close
|
|
k.tween(jaw1.pos.y, GROUND_Y - 10, 0.05, (v) => { jaw1.pos.y = v })
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 8)
|
|
if (isCritical) { screenFlash('#ff2d7b'); sfxExplosion() }
|
|
spawnSparks(def.pos.x, GROUND_Y - 10, 8, '#ff2d7b')
|
|
await k.wait(0.3)
|
|
trap.destroy()
|
|
jaw1.destroy()
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// SPEED BLITZ: afterimage dash — leaves ghost copies behind
|
|
async function afterimageDash(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
sfxSpecial()
|
|
const contactX = origDX - dir * 40
|
|
// Leave afterimages as we dash
|
|
const ghostCount = isCritical ? 6 : 4
|
|
const startX = atk.pos.x
|
|
for (let i = 0; i < ghostCount; i++) {
|
|
const ghost = k.add([
|
|
k.rect(20, 35),
|
|
k.pos(atk.pos.x, atk.pos.y - 25),
|
|
k.color(safeColor(k,theme.accent)),
|
|
k.opacity(0.4),
|
|
k.z(9),
|
|
])
|
|
setTimeout(() => { k.tween(0.4, 0, 0.3, (v) => { if (ghost.exists()) ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() }) }, 50)
|
|
// Move attacker a step forward
|
|
const stepX = startX + ((contactX - startX) / ghostCount) * (i + 1)
|
|
await k.tween(atk.pos.x, stepX, 0.03, (v) => { atk.pos.x = v })
|
|
}
|
|
atk.play('attack')
|
|
sfxPunch()
|
|
await k.wait(0.08)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 16 : 7)
|
|
// Speed lines
|
|
for (let i = 0; i < 5; i++) {
|
|
const lineY = GROUND_Y - 20 - Math.random() * 100
|
|
const line = k.add([
|
|
k.rect(W * 0.4, 2),
|
|
k.pos(dir === 1 ? origAX : origDX, lineY),
|
|
k.color(safeColor(k,theme.accent)),
|
|
k.opacity(0.5),
|
|
k.z(12),
|
|
])
|
|
k.tween(0.5, 0, 0.2, (v) => { if (line.exists()) line.opacity = v }).then(() => { if (line.exists()) line.destroy() })
|
|
}
|
|
if (isCritical) { screenFlash('#ffffff'); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 12, theme.accent)
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// BULLET TIME: slow-mo dodge, afterimages trail behind, golden traces, devastating counter
|
|
async function bulletTimeAttack(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Phase 1: Darken screen — entering bullet time
|
|
const dim = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#000011')), k.opacity(0), k.z(25)])
|
|
await k.tween(0, 0.45, 0.15, (v) => { dim.opacity = v })
|
|
scanlineGlitch(0.1)
|
|
|
|
// Phase 2: Attacker fires slow golden projectiles (bullets visible in slow-mo)
|
|
atk.play('special')
|
|
sfxGunshot()
|
|
const bulletCount = isCritical ? 5 : 3
|
|
const bullets: any[] = []
|
|
for (let i = 0; i < bulletCount; i++) {
|
|
const bx = atk.pos.x + dir * 25
|
|
const by = atk.pos.y - 30 + (i - 1) * 12
|
|
const bullet = k.add([
|
|
k.rect(isCritical ? 14 : 10, 3),
|
|
k.pos(bx, by),
|
|
k.color(safeColor(k, '#ffd700')),
|
|
k.opacity(0.9),
|
|
k.z(26),
|
|
k.anchor('center'),
|
|
k.rotate(dir === 1 ? 0 : 180),
|
|
])
|
|
bullets.push(bullet)
|
|
// Golden trace trail behind each bullet
|
|
const trace = k.add([
|
|
k.rect(40, 1),
|
|
k.pos(bx - dir * 20, by),
|
|
k.color(safeColor(k, '#ffaa00')),
|
|
k.opacity(0.5),
|
|
k.z(25),
|
|
])
|
|
k.tween(trace.opacity, 0, 0.4, (v) => { if (trace.exists()) trace.opacity = v }).then(() => { if (trace.exists()) trace.destroy() })
|
|
await k.wait(0.08) // Slow-mo pacing between shots
|
|
}
|
|
|
|
// Phase 3: Bullets travel slowly toward defender
|
|
await Promise.all(bullets.map((b, i) =>
|
|
k.tween(b.pos.x, def.pos.x + (Math.random() - 0.5) * 15, 0.25 + i * 0.03, (v) => { b.pos.x = v }, k.easings.easeInQuad)
|
|
))
|
|
bullets.forEach(b => { if (b.exists()) b.destroy() })
|
|
|
|
// Phase 4: Attacker dodges (lean back in slow-mo with afterimages)
|
|
const leanBack = dir * -30
|
|
for (let i = 0; i < 4; i++) {
|
|
const ghost = k.add([
|
|
k.rect(FRAME_SIZE * Math.abs(atk.scale.x) * 0.8, FRAME_SIZE * Math.abs(atk.scale.y)),
|
|
k.pos(atk.pos.x + i * leanBack * 0.15, atk.pos.y),
|
|
k.color(safeColor(k, '#ffd700')),
|
|
k.opacity(0.25 - i * 0.05),
|
|
k.z(24),
|
|
k.anchor('bot'),
|
|
])
|
|
setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 400)
|
|
}
|
|
await k.tween(atk.pos.x, atk.pos.x + leanBack, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
|
|
// Phase 5: Time resumes — attacker dashes in for devastating counter
|
|
dim.destroy()
|
|
sfxZoomWhoosh()
|
|
const contactX = origDX - dir * 40
|
|
// Afterimage trail during rush
|
|
const rushStart = atk.pos.x
|
|
for (let i = 0; i < 5; i++) {
|
|
const ghost = k.add([
|
|
k.rect(FRAME_SIZE * Math.abs(atk.scale.x) * 0.7, FRAME_SIZE * Math.abs(atk.scale.y) * 0.9),
|
|
k.pos(rushStart + ((contactX - rushStart) * i / 5), atk.pos.y),
|
|
k.color(safeColor(k, theme.accent)),
|
|
k.opacity(0.3 - i * 0.05),
|
|
k.z(9),
|
|
k.anchor('bot'),
|
|
])
|
|
setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 250)
|
|
}
|
|
await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
atk.play('attack')
|
|
|
|
// Phase 6: Impact
|
|
const hits = isCritical ? 4 : 2
|
|
for (let i = 0; i < hits; i++) {
|
|
sfxPunch()
|
|
def.play('hit')
|
|
k.shake(6 + i * 3)
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 15, def.pos.y - 20 - Math.random() * 20, 6, '#ffd700')
|
|
if (i < hits - 1) await k.wait(0.06)
|
|
}
|
|
// Final devastating blow
|
|
sfxCritical()
|
|
if (isCritical) { sfxExplosion(); screenFlash('#ffd700', 0.2) }
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 22 : 12)
|
|
spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 20 : 12, '#ffd700')
|
|
if (isCritical) spawnShockwave(def.pos.x, GROUND_Y, '#ffd700')
|
|
|
|
// Return to positions
|
|
const push = dir * (isCritical ? 70 : 35)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
atk.play('idle')
|
|
}
|
|
|
|
// INVISIBLE SPEED: so fast you can barely see — just blur lines and impact
|
|
async function lightningRush(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
sfxSpecial()
|
|
// Attacker vanishes
|
|
atk.opacity = 0
|
|
// Blur lines everywhere
|
|
for (let i = 0; i < 12; i++) {
|
|
const lineY = GROUND_Y - 10 - Math.random() * 120
|
|
const line = k.add([
|
|
k.rect(W, 2 + Math.random() * 2),
|
|
k.pos(0, lineY),
|
|
k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : theme.accent)),
|
|
k.opacity(0.6),
|
|
k.z(25),
|
|
])
|
|
k.tween(0.6, 0, 0.15, (v) => { if (line.exists()) line.opacity = v }).then(() => { if (line.exists()) line.destroy() })
|
|
}
|
|
// Multiple instant impacts across the screen (so fast, hits happen everywhere)
|
|
const hitCount = isCritical ? 8 : 5
|
|
for (let i = 0; i < hitCount; i++) {
|
|
const hx = origDX + (Math.random() - 0.5) * 100
|
|
const hy = GROUND_Y - 20 - Math.random() * 80
|
|
spawnSparks(hx, hy, 5, '#ffffff')
|
|
sfxPunch()
|
|
k.shake(3)
|
|
// Brief flash of attacker at hit position
|
|
atk.pos.x = hx - dir * 30
|
|
atk.opacity = 0.3
|
|
await k.wait(0.02)
|
|
atk.opacity = 0
|
|
await k.wait(0.01)
|
|
}
|
|
// Final massive hit
|
|
screenFlash('#ffffff', 0.15)
|
|
atk.pos.x = origDX - dir * 40
|
|
atk.opacity = 1
|
|
atk.play('attack')
|
|
sfxCritical()
|
|
await k.wait(0.05)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 25 : 12)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffffff')
|
|
if (isCritical) sfxExplosion()
|
|
const push = dir * (isCritical ? 140 : 70)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.15, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// ZOOM RUSH: fly toward camera (scale huge) then slam back into opponent
|
|
async function zoomRush(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const origAY = atk.pos.y
|
|
const origScaleX = atk.scale.x
|
|
const origScaleY = atk.scale.y
|
|
const origDefScaleX = def.scale.x
|
|
const origDefScaleY = def.scale.y
|
|
sfxZoomWhoosh()
|
|
// Randomize zoom level for variety (3x to 7x)
|
|
const zoomScale = isCritical ? 5 + Math.random() * 2 : 3 + Math.random() * 2
|
|
const zoomDur = 0.2 + Math.random() * 0.1
|
|
// Attacker rushes toward camera — scale up dramatically
|
|
// Defender shrinks to background (perspective effect)
|
|
await Promise.all([
|
|
k.tween(Math.abs(origScaleX), zoomScale, zoomDur, (v) => {
|
|
atk.scale.x = origScaleX > 0 ? v : -v
|
|
atk.scale.y = v
|
|
}, k.easings.easeInQuad),
|
|
k.tween(Math.abs(origDefScaleX), Math.abs(origDefScaleX) * 0.4, zoomDur, (v) => {
|
|
def.scale.x = origDefScaleX > 0 ? v : -v
|
|
def.scale.y = v
|
|
}, k.easings.easeInQuad),
|
|
k.tween(atk.pos.x, W / 2, zoomDur, (v) => { atk.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(atk.pos.y, H * 0.6, zoomDur, (v) => { atk.pos.y = v }, k.easings.easeOutQuad),
|
|
k.tween(1, 0.3, zoomDur, (v) => { atk.opacity = Math.max(0.3, v) }),
|
|
])
|
|
// Brief dramatic pause at huge size — screen darkens + grotesque close-up
|
|
screenFlash('#000000', 0.15)
|
|
if (zoomScale >= 4) spawnGrotesqueDetails(atk, zoomScale * 0.4)
|
|
k.shake(5)
|
|
await k.wait(0.1 + Math.random() * 0.08)
|
|
destroyGrotesqueDetails()
|
|
// Zoom back and SLAM into opponent (defender grows back)
|
|
sfxZoomWhoosh()
|
|
sfxSpecial()
|
|
await Promise.all([
|
|
k.tween(atk.scale.y, Math.abs(origScaleY), 0.15, (v) => {
|
|
atk.scale.x = origScaleX > 0 ? v : -v
|
|
atk.scale.y = v
|
|
}, k.easings.easeInQuad),
|
|
k.tween(Math.abs(def.scale.y), Math.abs(origDefScaleY), 0.15, (v) => {
|
|
def.scale.x = origDefScaleX > 0 ? v : -v
|
|
def.scale.y = v
|
|
}, k.easings.easeOutQuad),
|
|
k.tween(atk.pos.x, origDX - dir * 40, 0.15, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(atk.pos.y, origAY, 0.15, (v) => { atk.pos.y = v }, k.easings.easeInQuad),
|
|
k.tween(0.3, 1, 0.15, (v) => { atk.opacity = v }),
|
|
])
|
|
// IMPACT — rapid punches on contact
|
|
atk.play('attack')
|
|
const punchCount = isCritical ? 8 : 5
|
|
for (let i = 0; i < punchCount; i++) {
|
|
sfxRapidPunch()
|
|
def.play(i % 2 === 0 ? 'hit' : 'knockback')
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 40, def.pos.y - 10 - Math.random() * 50, 4, i % 2 === 0 ? '#ff2d7b' : '#ffcc00')
|
|
k.shake(2 + i)
|
|
def.pos.x += dir * 4
|
|
atk.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
await k.wait(0.04)
|
|
}
|
|
// Final haymaker
|
|
sfxCritical()
|
|
k.shake(isCritical ? 25 : 15)
|
|
screenFlash(isCritical ? '#ffffff' : '#ff2d7b', 0.2)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffffff')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff2d7b')
|
|
if (isCritical) sfxExplosion()
|
|
const push = dir * (isCritical ? 150 : 80)
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
// Return — restore both scales
|
|
atk.scale.x = origScaleX
|
|
atk.scale.y = origScaleY
|
|
def.scale.x = origDefScaleX
|
|
def.scale.y = origDefScaleY
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// RAPID FLURRY: DBZ-style machine gun punches — so fast it's a blur
|
|
async function rapidFlurry(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 40
|
|
// Sprint to contact
|
|
sfxSpecial()
|
|
await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
// UNLEASH — rapid alternating punches
|
|
const hitCount = isCritical ? 14 : 8
|
|
const colors = ['#ff2d7b', '#ffcc00', '#00f0ff', '#ff6600', '#ffffff']
|
|
// Speed lines during flurry
|
|
for (let i = 0; i < 6; i++) {
|
|
const lineY = GROUND_Y - 10 - Math.random() * 100
|
|
const line = k.add([
|
|
k.rect(W * 0.5, 2),
|
|
k.pos(dir === 1 ? origAX - 50 : origDX + 50, lineY),
|
|
k.color(safeColor(k,colors[i % colors.length])),
|
|
k.opacity(0.4),
|
|
k.z(12),
|
|
])
|
|
k.tween(0.4, 0, 0.5, (v) => { if (line.exists()) line.opacity = v }).then(() => { if (line.exists()) line.destroy() })
|
|
}
|
|
for (let i = 0; i < hitCount; i++) {
|
|
const anim = ['attack', 'kick', 'attack', 'special'][i % 4]
|
|
atk.play(anim)
|
|
sfxRapidPunch()
|
|
// Sparks spray in alternating directions
|
|
const sparkX = def.pos.x + (i % 2 === 0 ? -1 : 1) * (10 + Math.random() * 20)
|
|
const sparkY = def.pos.y - 10 - Math.random() * 50
|
|
spawnSparks(sparkX, sparkY, 3, colors[i % colors.length])
|
|
def.play('hit')
|
|
k.shake(2)
|
|
// Jitter defender rapidly side to side
|
|
def.pos.x += (i % 2 === 0 ? dir : -dir) * 6
|
|
def.pos.y += (i % 2 === 0 ? -3 : 3)
|
|
await k.wait(0.035)
|
|
}
|
|
// Pause — "Is it over?"
|
|
await k.wait(0.08)
|
|
// ONE MORE — the big finish
|
|
atk.play('special')
|
|
sfxCritical()
|
|
sfxPunch()
|
|
await k.wait(0.06)
|
|
def.play('knockback')
|
|
k.shake(isCritical ? 22 : 12)
|
|
screenFlash(isCritical ? '#ffffff' : '#ffcc00', 0.15)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 12, '#ffcc00')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffcc00')
|
|
if (isCritical) sfxExplosion()
|
|
const push = dir * (isCritical ? 140 : 70)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
// Reset defender Y
|
|
k.tween(def.pos.y, GROUND_Y - 6, 0.2, (v) => { def.pos.y = v })
|
|
await k.wait(0.35)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// SWORD SLASH: giant energy sword swing with arc trail
|
|
async function swordSlash(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 50
|
|
await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Draw sword arc (series of rects forming a crescent)
|
|
const arcParts: any[] = []
|
|
for (let i = 0; i < 8; i++) {
|
|
const angle = (i / 8) * Math.PI - Math.PI / 4
|
|
const ax = contactX + dir * 20 + Math.cos(angle) * 60
|
|
const ay = atk.pos.y - 40 + Math.sin(angle) * 60
|
|
const part = k.add([
|
|
k.rect(25, 3), k.pos(ax, ay), k.color(safeColor(k,isCritical ? '#00f0ff' : '#aaddff')),
|
|
k.opacity(0.9), k.z(18), k.rotate(angle * 180 / Math.PI + 90),
|
|
])
|
|
arcParts.push(part)
|
|
}
|
|
await k.wait(0.08)
|
|
// Slash hits
|
|
arcParts.forEach(p => { k.tween(0.9, 0, 0.2, (v) => { if (p.exists()) p.opacity = v }).then(() => { if (p.exists()) p.destroy() }) })
|
|
sfxKick()
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#aaddff')
|
|
if (isCritical) { screenFlash('#00f0ff'); sfxCritical() }
|
|
const push = dir * (isCritical ? 110 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// HAMMER SMASH: pull out giant hammer, overhead slam with ground crack
|
|
async function hammerSmash(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 40
|
|
await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
// Giant hammer
|
|
const handle = k.add([k.rect(6, 70), k.pos(atk.pos.x + dir * 15, atk.pos.y - 90), k.color(safeColor(k,'#8B4513')), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -60 : 60)])
|
|
const head = k.add([k.rect(30, 20), k.pos(handle.pos.x + dir * 10, handle.pos.y - 60), k.color(safeColor(k,'#888888')), k.z(18)])
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Swing hammer down
|
|
await k.tween(dir === 1 ? -60 : 60, dir === 1 ? 70 : -70, 0.15, (v) => { handle.angle = v }, k.easings.easeInQuad)
|
|
handle.destroy(); head.destroy()
|
|
sfxExplosion()
|
|
// Ground crack effect
|
|
for (let i = 0; i < 6; i++) {
|
|
const crack = k.add([k.rect(2, 15 + Math.random() * 20), k.pos(def.pos.x + (i - 3) * 12, GROUND_Y - 5), k.color(safeColor(k,'#ff6600')), k.opacity(0.8), k.z(4)])
|
|
setTimeout(() => { k.tween(0.8, 0, 0.6, (v) => { if (crack.exists()) crack.opacity = v }).then(() => { if (crack.exists()) crack.destroy() }) }, 400)
|
|
}
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 25 : 14)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 18, '#ff6600')
|
|
if (isCritical) screenFlash('#ff6600')
|
|
const push = dir * (isCritical ? 120 : 55)
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// LASER BEAM: charge up then fire continuous beam across screen
|
|
async function laserBeam(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Charge up (growing ball of energy)
|
|
const chargeX = atk.pos.x + dir * 25
|
|
const chargeY = atk.pos.y - 40
|
|
const charge = k.add([k.circle(3), k.pos(chargeX, chargeY), k.color(safeColor(k,'#ff2d2d')), k.opacity(0.9), k.z(18)])
|
|
await k.tween(3, isCritical ? 18 : 12, 0.3, (v) => { charge.radius = v }, k.easings.easeOutQuad)
|
|
sfxZap()
|
|
// FIRE BEAM (long rect that stretches across)
|
|
charge.destroy()
|
|
const beamLen = Math.abs(def.pos.x - chargeX) + 30
|
|
const beam = k.add([k.rect(beamLen, isCritical ? 12 : 7), k.pos(chargeX, chargeY), k.color(safeColor(k,isCritical ? '#ff2d2d' : '#ff6600')), k.opacity(0.9), k.z(18)])
|
|
// Core beam (brighter, thinner)
|
|
const core = k.add([k.rect(beamLen, isCritical ? 5 : 3), k.pos(chargeX, chargeY + 2), k.color(safeColor(k,'#ffffff')), k.opacity(0.8), k.z(19)])
|
|
k.shake(isCritical ? 12 : 6)
|
|
screenFlash(isCritical ? '#ff2d2d' : '#ff6600', 0.15)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
sfxCritical()
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ff2d2d')
|
|
await k.wait(0.25)
|
|
// Beam fades
|
|
k.tween(0.9, 0, 0.15, (v) => { beam.opacity = v; core.opacity = v * 0.8 }).then(() => { beam.destroy(); core.destroy() })
|
|
const push = dir * (isCritical ? 130 : 60)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// ROCKET LAUNCHER: fire a big slow rocket that explodes on impact
|
|
async function rocketLauncher(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.15)
|
|
// Rocket body
|
|
const rocketX = atk.pos.x + dir * 30
|
|
const rocketY = atk.pos.y - 35
|
|
const rocket = k.add([k.rect(20, 8), k.pos(rocketX, rocketY), k.color(safeColor(k,'#888888')), k.z(17), k.rotate(dir === 1 ? 0 : 180)])
|
|
const nose = k.add([k.rect(6, 6), k.pos(rocketX + dir * 12, rocketY + 1), k.color(safeColor(k,'#ff2d2d')), k.z(17)])
|
|
sfxJetpack()
|
|
// Trail exhaust as rocket flies
|
|
const exhaustInt = setInterval(() => {
|
|
const p = k.add([k.circle(3 + Math.random() * 4), k.pos(rocket.pos.x - dir * 12, rocket.pos.y + (Math.random() - 0.5) * 8), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(16)])
|
|
p.onUpdate(() => { p.pos.x -= dir * 100 * k.dt(); p.opacity -= 3 * k.dt(); if (p.opacity <= 0) p.destroy() })
|
|
}, 30)
|
|
// Fly rocket to target
|
|
const dur = 0.3
|
|
await k.tween(0, 1, dur, (t) => { rocket.pos.x = rocketX + (def.pos.x - rocketX) * t; nose.pos.x = rocket.pos.x + dir * 12; rocket.pos.y = rocketY + Math.sin(t * Math.PI * 3) * 8; nose.pos.y = rocket.pos.y + 1 }, k.easings.linear)
|
|
clearInterval(exhaustInt)
|
|
rocket.destroy(); nose.destroy()
|
|
// EXPLOSION!
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 30 : 18)
|
|
screenFlash('#ff6600', 0.2)
|
|
// Explosion rings
|
|
for (let i = 0; i < 3; i++) {
|
|
setTimeout(() => spawnShockwave(def.pos.x, def.pos.y - 20, ['#ff6600', '#ff2d2d', '#ffcc00'][i]), i * 60)
|
|
}
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ff6600')
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
const push = dir * (isCritical ? 160 : 80)
|
|
const origDY = def.pos.y
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.y, origDY - (isCritical ? 120 : 60), 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(def.pos.y, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// BOMB THROW: lob an arcing bomb that bounces once then detonates
|
|
async function bombThrow(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('attack')
|
|
sfxSpecial()
|
|
const bombX = atk.pos.x + dir * 20
|
|
const bombY = atk.pos.y - 50
|
|
const bomb = k.add([k.circle(8), k.pos(bombX, bombY), k.color(safeColor(k,'#333333')), k.z(17)])
|
|
const fuse = k.add([k.rect(2, 8), k.pos(bombX, bombY - 8), k.color(safeColor(k,'#ff6600')), k.z(17)])
|
|
// Arc to opponent
|
|
const midX = (bombX + def.pos.x) / 2
|
|
await k.tween(0, 1, 0.35, (t) => {
|
|
bomb.pos.x = bombX + (def.pos.x - bombX) * t
|
|
bomb.pos.y = bombY + (GROUND_Y - 10 - bombY) * t - Math.sin(t * Math.PI) * 120
|
|
fuse.pos.x = bomb.pos.x; fuse.pos.y = bomb.pos.y - 8
|
|
}, k.easings.linear)
|
|
fuse.destroy()
|
|
// Bounce
|
|
sfxBonk()
|
|
await k.tween(bomb.pos.y, bomb.pos.y - 30, 0.08, (v) => { bomb.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(bomb.pos.y, GROUND_Y - 10, 0.08, (v) => { bomb.pos.y = v }, k.easings.easeInQuad)
|
|
// BOOM
|
|
bomb.destroy()
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 28 : 15)
|
|
screenFlash(isCritical ? '#ff2d2d' : '#ff6600', 0.2)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 20, '#ffcc00')
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
const push = dir * (isCritical ? 120 : 55)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// MINIGUN SPRAY: sustained automatic fire with shell casings
|
|
async function minigunSpray(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
// Spawn visible pixel minigun
|
|
const gpx = atk.pos.x + dir * 15
|
|
const gpy = atk.pos.y - 38
|
|
const mgDef = PROPS.minigun
|
|
const mgObjs = spawnWeaponProp('minigun', gpx, gpy, dir < 0, 18)
|
|
await k.wait(0.1)
|
|
sfxSpecial()
|
|
const bulletCount = isCritical ? 16 : 10
|
|
const muzzleX = gpx + dir * 30
|
|
for (let i = 0; i < bulletCount; i++) {
|
|
sfxGunshot()
|
|
// Muzzle flash
|
|
const flash = k.add([k.circle(4 + Math.random() * 4), k.pos(muzzleX, gpy + (Math.random() - 0.5) * 6), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')])
|
|
setTimeout(() => { if (flash.exists()) flash.destroy() }, 30)
|
|
const spread = (Math.random() - 0.5) * 50
|
|
spawnBullet(muzzleX, gpy, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread)
|
|
// Shell casing
|
|
const casing = k.add([k.rect(3, 2), k.pos(gpx - dir * 5, gpy - 5), k.color(k.Color.fromHex('#ffd700')), k.opacity(0.8), k.z(16)])
|
|
const casVx = -dir * (100 + Math.random() * 100)
|
|
const casVy = -200 - Math.random() * 100
|
|
casing.onUpdate(() => { casing.pos.x += casVx * k.dt(); casing.pos.y += casVy * k.dt() + 500 * k.dt() * k.dt(); casing.opacity -= 1.5 * k.dt(); if (casing.opacity <= 0) casing.destroy() })
|
|
// Recoil gun + attacker
|
|
if (mgDef) moveProp(mgObjs, mgDef, gpx - dir * 3, gpy, dir < 0)
|
|
atk.pos.x -= dir * 2
|
|
await k.wait(0.01)
|
|
if (mgDef) moveProp(mgObjs, mgDef, gpx, gpy, dir < 0)
|
|
atk.pos.x += dir * 2
|
|
if (i % 2 === 0) { def.opacity = 0.3; await k.wait(0.01); def.opacity = 1 }
|
|
sfxBulletHit()
|
|
await k.wait(0.04)
|
|
}
|
|
destroyProp(mgObjs)
|
|
spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 8 : 5)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 10)
|
|
if (isCritical) { screenFlash('#ffee00'); sfxExplosion() }
|
|
const push = dir * (isCritical ? 120 : 55)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// SNIPER SHOT: long pause, laser sight, then one devastating hit
|
|
async function sniperShot(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Back up to wall
|
|
const wallX = dir === 1 ? 30 : W - 30
|
|
await k.tween(atk.pos.x, wallX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('special')
|
|
// Laser sight line
|
|
const laserY = atk.pos.y - 38
|
|
const laser = k.add([k.rect(Math.abs(def.pos.x - wallX), 1), k.pos(Math.min(wallX, def.pos.x), laserY), k.color(safeColor(k,'#ff0000')), k.opacity(0.5), k.z(15)])
|
|
// Wobble the laser for "aiming"
|
|
for (let i = 0; i < 8; i++) {
|
|
laser.pos.y = laserY + (Math.random() - 0.5) * 15
|
|
await k.wait(0.06)
|
|
}
|
|
laser.pos.y = def.pos.y - 35
|
|
await k.wait(0.15)
|
|
// FIRE
|
|
laser.destroy()
|
|
sfxGunshot()
|
|
sfxCritical()
|
|
screenFlash('#ffffff', 0.1)
|
|
// Tracer (bright line from attacker to defender)
|
|
const tracer = k.add([k.rect(Math.abs(def.pos.x - wallX), 3), k.pos(Math.min(wallX, def.pos.x), def.pos.y - 35), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)])
|
|
k.tween(0.9, 0, 0.1, (v) => { tracer.opacity = v }).then(() => tracer.destroy())
|
|
// Impact
|
|
def.play('knockback')
|
|
k.shake(isCritical ? 22 : 12)
|
|
spawnSparks(def.pos.x, def.pos.y - 35, 18, '#ffee00')
|
|
if (isCritical) { sfxExplosion(); screenFlash('#ff2d2d', 0.15) }
|
|
const push = dir * (isCritical ? 130 : 65)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// WHIP CRACK: ranged whip attack with crack sound
|
|
async function whipCrack(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('attack')
|
|
sfxSpecial()
|
|
await k.wait(0.1)
|
|
// Whip extends from attacker to defender (series of small segments)
|
|
const segments: any[] = []
|
|
const fromX = atk.pos.x + dir * 20
|
|
const fromY = atk.pos.y - 40
|
|
const toX = def.pos.x
|
|
const toY = def.pos.y - 30
|
|
const segCount = 12
|
|
for (let i = 0; i < segCount; i++) {
|
|
const t = i / segCount
|
|
const sx = fromX + (toX - fromX) * t
|
|
const sy = fromY + (toY - fromY) * t + Math.sin(t * Math.PI * 3) * 20
|
|
const seg = k.add([k.rect(6, 3), k.pos(sx, sy), k.color(safeColor(k,'#8B4513')), k.opacity(0), k.z(17)])
|
|
segments.push(seg)
|
|
}
|
|
// Animate whip extending (each segment appears in sequence)
|
|
for (let i = 0; i < segments.length; i++) {
|
|
segments[i].opacity = 0.9
|
|
await k.wait(0.01)
|
|
}
|
|
// CRACK!
|
|
sfxPunch()
|
|
sfxBonk()
|
|
spawnSparks(toX, toY, 10, '#ffcc00')
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 14 : 7)
|
|
if (isCritical) screenFlash('#ffcc00')
|
|
// Whip retracts
|
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
if (segments[i].exists()) segments[i].destroy()
|
|
await k.wait(0.008)
|
|
}
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// KATANA COMBO: triple slash with cross patterns
|
|
async function katanaCombo(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 45
|
|
sfxSpecial()
|
|
await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
const slashCount = isCritical ? 5 : 3
|
|
for (let i = 0; i < slashCount; i++) {
|
|
atk.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxPunch()
|
|
// Slash trail (diagonal line)
|
|
const angle = [45, -45, 0, 135, -135][i % 5]
|
|
const slash = k.add([k.rect(50, 3), k.pos(def.pos.x, def.pos.y - 30), k.color(safeColor(k,'#aaddff')), k.opacity(0.9), k.z(18), k.rotate(angle), k.anchor('center')])
|
|
k.tween(0.9, 0, 0.15, (v) => { if (slash.exists()) slash.opacity = v }).then(() => { if (slash.exists()) slash.destroy() })
|
|
def.play('hit')
|
|
k.shake(4 + i * 2)
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 - Math.random() * 30, 5, '#aaddff')
|
|
await k.wait(0.1)
|
|
}
|
|
// Final slash — big X mark
|
|
if (isCritical) {
|
|
const s1 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(safeColor(k,'#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(45), k.anchor('center')])
|
|
const s2 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(safeColor(k,'#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(-45), k.anchor('center')])
|
|
sfxCritical()
|
|
screenFlash('#00f0ff')
|
|
k.shake(20)
|
|
await k.wait(0.2)
|
|
k.tween(0.9, 0, 0.3, (v) => { if (s1.exists()) s1.opacity = v; if (s2.exists()) s2.opacity = v }).then(() => { if (s1.exists()) s1.destroy(); if (s2.exists()) s2.destroy() })
|
|
}
|
|
def.play('knockback')
|
|
const push = dir * (isCritical ? 110 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// CHAINSAW: charge with buzzing chainsaw, multi-hit grind
|
|
async function chainsawRev(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 35
|
|
atk.play('special')
|
|
// Chainsaw buzzing (visual: vibrating rect)
|
|
const saw = k.add([k.rect(40, 8), k.pos(atk.pos.x + dir * 25, atk.pos.y - 35), k.color(safeColor(k,'#888888')), k.z(18)])
|
|
const blade = k.add([k.rect(35, 3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 32), k.color(safeColor(k,'#ffcc00')), k.z(19)])
|
|
sfxSpecial()
|
|
// Rev up (shake the saw)
|
|
for (let i = 0; i < 8; i++) {
|
|
saw.pos.y += (Math.random() - 0.5) * 4
|
|
blade.pos.y = saw.pos.y + 3
|
|
await k.wait(0.03)
|
|
}
|
|
// Charge forward
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, contactX, 0.15, (v) => { atk.pos.x = v; saw.pos.x = v + dir * 25; blade.pos.x = v + dir * 30 }, k.easings.easeInQuad),
|
|
])
|
|
// GRIND — multi-hit with sparks
|
|
const hits = isCritical ? 6 : 4
|
|
for (let i = 0; i < hits; i++) {
|
|
sfxPunch()
|
|
spawnSparks(def.pos.x - dir * 10, def.pos.y - 20 - Math.random() * 30, 5, '#ffcc00')
|
|
def.play('hit')
|
|
k.shake(3)
|
|
saw.pos.y += (Math.random() - 0.5) * 6
|
|
blade.pos.y = saw.pos.y + 3
|
|
await k.wait(0.05)
|
|
}
|
|
saw.destroy(); blade.destroy()
|
|
if (isCritical) { sfxCritical(); screenFlash('#ff6600') }
|
|
def.play('knockback')
|
|
k.shake(isCritical ? 18 : 10)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ff6600')
|
|
const push = dir * (isCritical ? 110 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// MOTORBIKE CHARGE: ride a motorbike across the screen and slam into defender
|
|
async function motorbikeCharge(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Back up to edge
|
|
const startX = dir === 1 ? -30 : W + 30
|
|
atk.opacity = 0
|
|
atk.pos.x = startX
|
|
// Build the bike: body + wheels
|
|
const bikeY = GROUND_Y - 15
|
|
const bike = k.add([k.rect(50, 18), k.pos(startX, bikeY), k.color(safeColor(k,isCritical ? '#ff2d2d' : '#4488ff')), k.z(16)])
|
|
const wheelF = k.add([k.circle(8), k.pos(startX + dir * 18, bikeY + 12), k.color(safeColor(k,'#333333')), k.z(16)])
|
|
const wheelR = k.add([k.circle(8), k.pos(startX - dir * 16, bikeY + 12), k.color(safeColor(k,'#333333')), k.z(16)])
|
|
sfxJetpack()
|
|
// Tire marks + exhaust as it zooms across
|
|
const exh = setInterval(() => {
|
|
const p = k.add([k.circle(3), k.pos(bike.pos.x - dir * 25, bikeY + 5), k.color(safeColor(k,'#888888')), k.opacity(0.5), k.z(4)])
|
|
p.onUpdate(() => { p.opacity -= 2 * k.dt(); if (p.opacity <= 0) p.destroy() })
|
|
}, 30)
|
|
// ZOOM across screen
|
|
const targetX = origDX + dir * 30
|
|
await k.tween(startX, targetX, 0.3, (v) => {
|
|
bike.pos.x = v; wheelF.pos.x = v + dir * 18; wheelR.pos.x = v - dir * 16
|
|
}, k.easings.easeInQuad)
|
|
clearInterval(exh)
|
|
// IMPACT
|
|
sfxExplosion()
|
|
sfxCritical()
|
|
bike.destroy(); wheelF.destroy(); wheelR.destroy()
|
|
k.shake(isCritical ? 25 : 14)
|
|
screenFlash(isCritical ? '#ff2d2d' : '#4488ff', 0.2)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 20, '#ff6600')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
def.play('knockback')
|
|
const push = dir * (isCritical ? 160 : 80)
|
|
const origDY = def.pos.y
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.y, origDY - 100, 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(def.pos.y, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
// Attacker reappears at home
|
|
atk.pos.x = origAX
|
|
atk.opacity = 1
|
|
atk.play('idle')
|
|
await k.wait(0.2)
|
|
await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// CAR SMASH: drive a car from off-screen, defender gets sent flying
|
|
async function carSmash(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.opacity = 0
|
|
const carY = GROUND_Y - 20
|
|
const startX = dir === 1 ? -60 : W + 60
|
|
// Car body
|
|
const car = k.add([k.rect(70, 25), k.pos(startX, carY), k.color(safeColor(k,isCritical ? '#ffd700' : '#ff6600')), k.z(16)])
|
|
// Roof
|
|
const roof = k.add([k.rect(35, 15), k.pos(startX + dir * 8, carY - 15), k.color(safeColor(k,isCritical ? '#ccaa00' : '#cc5500')), k.z(16)])
|
|
// Wheels
|
|
const w1 = k.add([k.circle(8), k.pos(startX + dir * 22, carY + 18), k.color(safeColor(k,'#222222')), k.z(16)])
|
|
const w2 = k.add([k.circle(8), k.pos(startX - dir * 22, carY + 18), k.color(safeColor(k,'#222222')), k.z(16)])
|
|
// Headlight
|
|
const hl = k.add([k.rect(4, 6), k.pos(startX + dir * 35, carY + 3), k.color(safeColor(k,'#ffee00')), k.z(17)])
|
|
sfxJetpack()
|
|
sfxSpecial()
|
|
// DRIVE ACROSS
|
|
await k.tween(startX, def.pos.x, 0.35, (v) => {
|
|
car.pos.x = v; roof.pos.x = v + dir * 8; w1.pos.x = v + dir * 22; w2.pos.x = v - dir * 22; hl.pos.x = v + dir * 35
|
|
}, k.easings.easeInQuad)
|
|
// HIT!
|
|
car.destroy(); roof.destroy(); w1.destroy(); w2.destroy(); hl.destroy()
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 30 : 18)
|
|
screenFlash('#ff6600', 0.2)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 25, '#ffcc00')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
def.play('knockback')
|
|
const push = dir * (isCritical ? 180 : 90)
|
|
const origDY = def.pos.y
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + push, 0.4, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.y, origDY - 150, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(def.pos.y, origDY, 0.25, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxBoing()
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffcc00')
|
|
atk.pos.x = origAX
|
|
atk.opacity = 1
|
|
atk.play('idle')
|
|
await k.wait(0.2)
|
|
await k.tween(def.pos.x, origDX, 0.4, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// BOAT CANNON: a boat slides across the ground and fires cannons
|
|
async function boatCannon(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Build boat
|
|
const boatX = atk.pos.x
|
|
const boatY = GROUND_Y - 10
|
|
const hull = k.add([k.rect(60, 20), k.pos(boatX, boatY), k.color(safeColor(k,'#8B4513')), k.z(8)])
|
|
const mast = k.add([k.rect(3, 40), k.pos(boatX + 10, boatY - 40), k.color(safeColor(k,'#8B4513')), k.z(8)])
|
|
const sail = k.add([k.rect(25, 30), k.pos(boatX + 15, boatY - 45), k.color(safeColor(k,'#ffffff')), k.opacity(0.8), k.z(8)])
|
|
// Slide boat forward
|
|
sfxJetpack()
|
|
const moveX = (origAX + origDX) / 2
|
|
await k.tween(boatX, moveX, 0.3, (v) => { hull.pos.x = v; mast.pos.x = v + 10; sail.pos.x = v + 15 }, k.easings.easeOutQuad)
|
|
// Fire cannons!
|
|
const shotCount = isCritical ? 5 : 3
|
|
for (let i = 0; i < shotCount; i++) {
|
|
sfxGunshot()
|
|
// Cannonball
|
|
const ballY = boatY - 5 + (Math.random() - 0.5) * 10
|
|
await spawnProjectile(moveX + dir * 30, ballY, def.pos.x, def.pos.y - 20 + (Math.random() - 0.5) * 30, '#333333', 7)
|
|
sfxBulletHit()
|
|
def.play('hit')
|
|
k.shake(5)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 5, '#ff6600')
|
|
await k.wait(0.08)
|
|
}
|
|
// Final broadside
|
|
if (isCritical) {
|
|
sfxExplosion()
|
|
screenFlash('#ff6600')
|
|
k.shake(20)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ff6600')
|
|
}
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
hull.destroy(); mast.destroy(); sail.destroy()
|
|
const push = dir * (isCritical ? 110 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// ANVIL DROP: classic cartoon — anvil falls from sky onto opponent
|
|
async function anvilDrop(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.15)
|
|
// Shadow on ground growing
|
|
const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(safeColor(k,'#000000')), k.opacity(0.3), k.z(4)])
|
|
k.tween(5, 25, 0.3, (v) => { shadow.radius = v })
|
|
// Anvil falls from sky
|
|
const anvil = k.add([k.rect(35, 25), k.pos(def.pos.x - 17, -30), k.color(safeColor(k,'#555555')), k.opacity(0.9), k.z(18)])
|
|
const base = k.add([k.rect(45, 8), k.pos(def.pos.x - 22, -8), k.color(safeColor(k,'#444444')), k.opacity(0.9), k.z(18)])
|
|
sfxSlideDown()
|
|
await k.tween(-30, def.pos.y - 50, 0.25, (v) => { anvil.pos.y = v; base.pos.y = v + 22 }, k.easings.easeInQuad)
|
|
// BONK!
|
|
sfxBonk()
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 25 : 15)
|
|
screenFlash(isCritical ? '#ffffff' : '#888888', 0.15)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#888888')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#888888')
|
|
shadow.destroy()
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
// Anvil bounces
|
|
await k.tween(anvil.pos.y, anvil.pos.y - 40, 0.1, (v) => { anvil.pos.y = v; base.pos.y = v + 22 }, k.easings.easeOutQuad)
|
|
await k.tween(anvil.pos.y, anvil.pos.y + 40, 0.1, (v) => { anvil.pos.y = v; base.pos.y = v + 22 }, k.easings.easeInQuad)
|
|
sfxBonk()
|
|
await k.wait(0.15)
|
|
k.tween(0.9, 0, 0.3, (v) => { anvil.opacity = v; base.opacity = v }).then(() => { anvil.destroy(); base.destroy() })
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// POCKET CANNON: reach into pocket, pull out comically oversized gun, fire
|
|
async function pocketCannon(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// "Reach into pocket" — arm down motion
|
|
atk.play('special')
|
|
await k.wait(0.12)
|
|
|
|
// Gun appears — comically oversized
|
|
const gunLen = isCritical ? 90 : 65
|
|
const gunH = isCritical ? 22 : 16
|
|
const gx = atk.pos.x + dir * 20
|
|
const gy = atk.pos.y - 25
|
|
// Barrel
|
|
const barrel = k.add([
|
|
k.rect(gunLen, gunH), k.pos(gx, gy),
|
|
k.color(safeColor(k,'#333333')), k.opacity(1), k.z(16), k.scale(1),
|
|
])
|
|
// Handle
|
|
const handle = k.add([
|
|
k.rect(12, 20), k.pos(gx - dir * 5, gy + gunH / 2),
|
|
k.color(safeColor(k,'#555555')), k.opacity(1), k.z(15), k.scale(1),
|
|
])
|
|
// Scope on top
|
|
const scope = k.add([
|
|
k.circle(5), k.pos(gx + dir * gunLen * 0.6, gy - 6),
|
|
k.color(safeColor(k,'#880000')), k.opacity(0.9), k.z(17), k.scale(1),
|
|
])
|
|
|
|
// Gun grows in from small — comedy "pulling from tiny pocket" effect
|
|
barrel.scale = k.vec2(0.1, 0.1)
|
|
handle.scale = k.vec2(0.1, 0.1)
|
|
scope.scale = k.vec2(0.1, 0.1)
|
|
await Promise.all([
|
|
k.tween(0.1, 1, 0.15, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack),
|
|
k.tween(0.1, 1, 0.15, (v) => { handle.scale = k.vec2(v, v) }, k.easings.easeOutBack),
|
|
k.tween(0.1, 1, 0.15, (v) => { scope.scale = k.vec2(v, v) }, k.easings.easeOutBack),
|
|
])
|
|
sfxBoing()
|
|
k.shake(3)
|
|
await k.wait(0.08)
|
|
|
|
// AIM — brief wobble
|
|
const wobbleTime = 0.12
|
|
await k.tween(0, wobbleTime, wobbleTime, (t) => {
|
|
const w = Math.sin(t * 40) * 3
|
|
barrel.pos.y = gy + w
|
|
handle.pos.y = gy + gunH / 2 + w
|
|
scope.pos.y = gy - 6 + w
|
|
})
|
|
|
|
// FIRE! massive shot
|
|
const shotCount = isCritical ? 4 : 2
|
|
for (let s = 0; s < shotCount; s++) {
|
|
sfxGunshot()
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 15 : 10)
|
|
// Muzzle flash
|
|
const flash = k.add([
|
|
k.circle(12 + Math.random() * 8),
|
|
k.pos(gx + dir * gunLen, gy),
|
|
k.color(safeColor(k,'#ffee00')),
|
|
k.opacity(0.9), k.z(18),
|
|
])
|
|
setTimeout(() => { if (flash.exists()) flash.destroy() }, 60)
|
|
// Recoil
|
|
const recoilX = atk.pos.x - dir * 15
|
|
k.tween(atk.pos.x, recoilX, 0.04, (v) => {
|
|
atk.pos.x = v
|
|
barrel.pos.x = v + dir * 20
|
|
handle.pos.x = v + dir * 20 - dir * 5
|
|
scope.pos.x = v + dir * 20 + dir * gunLen * 0.6
|
|
}).then(() => k.tween(recoilX, origAX, 0.06, (v) => {
|
|
atk.pos.x = v
|
|
barrel.pos.x = v + dir * 20
|
|
handle.pos.x = v + dir * 20 - dir * 5
|
|
scope.pos.x = v + dir * 20 + dir * gunLen * 0.6
|
|
}))
|
|
// Massive projectile
|
|
await spawnProjectile(gx + dir * gunLen, gy, def.pos.x, def.pos.y - 20 + (Math.random() - 0.5) * 20, '#ffcc00', 10)
|
|
sfxBulletHit()
|
|
def.play(s === shotCount - 1 ? 'knockback' : 'hit')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 10, '#ff6600')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
def.pos.x += dir * (isCritical ? 20 : 12)
|
|
if (s < shotCount - 1) await k.wait(0.06)
|
|
}
|
|
|
|
// Gun disappears — shrinks back into pocket
|
|
await Promise.all([
|
|
k.tween(1, 0, 0.12, (v) => { barrel.scale = k.vec2(v, v) }),
|
|
k.tween(1, 0, 0.12, (v) => { handle.scale = k.vec2(v, v) }),
|
|
k.tween(1, 0, 0.12, (v) => { scope.scale = k.vec2(v, v) }),
|
|
])
|
|
barrel.destroy(); handle.destroy(); scope.destroy()
|
|
sfxBoing()
|
|
|
|
if (isCritical) screenFlash('#ff6600', 0.15)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// GRAPPLE FLURRY: both bots close in and trade sustained blows without splitting apart
|
|
async function grappleFlurry(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Both rush to center — meet in the middle
|
|
const meetX = (origAX + origDX) / 2
|
|
const atkMeetX = meetX - dir * 25
|
|
const defMeetX = meetX + dir * 25
|
|
sfxSpecial()
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, atkMeetX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.x, defMeetX, 0.12, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
])
|
|
|
|
// Sustained trading blows — attacker dominates but defender fights back
|
|
const hitCount = isCritical ? 10 : 7
|
|
for (let i = 0; i < hitCount; i++) {
|
|
const atkHits = i % 3 !== 2 // attacker hits 2/3 of the time
|
|
if (atkHits) {
|
|
atk.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxPunch()
|
|
await k.wait(0.03)
|
|
def.play('hit')
|
|
spawnSparks(def.pos.x - dir * 5, def.pos.y - 25 - Math.random() * 20, 3, i % 2 === 0 ? '#ff2d7b' : '#ffcc00')
|
|
k.shake(2 + Math.floor(i / 2))
|
|
// Push defender slightly but attacker follows
|
|
def.pos.x += dir * 3
|
|
atk.pos.x += dir * 2
|
|
} else {
|
|
// Defender strikes back!
|
|
def.play('attack')
|
|
sfxKick()
|
|
await k.wait(0.03)
|
|
atk.play('hit')
|
|
spawnSparks(atk.pos.x + dir * 5, atk.pos.y - 25 - Math.random() * 20, 3, '#00f0ff')
|
|
k.shake(2)
|
|
atk.pos.x -= dir * 3
|
|
def.pos.x -= dir * 2
|
|
}
|
|
await k.wait(0.05)
|
|
}
|
|
|
|
// Attacker finishes with a shove
|
|
atk.play('special')
|
|
sfxCritical()
|
|
k.shake(isCritical ? 20 : 12)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 12, '#ff2d7b')
|
|
if (isCritical) {
|
|
screenFlash('#ff2d7b', 0.15)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff2d7b')
|
|
}
|
|
def.play('knockback')
|
|
const pushDist = dir * (isCritical ? 120 : 60)
|
|
k.tween(def.pos.x, origDX + pushDist * 0.5, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.25)
|
|
|
|
// Both return
|
|
atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.25, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// BODY SLAM: wrestling-style grab, lift, and slam
|
|
async function bodySlam(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Rush in close
|
|
sfxSpecial()
|
|
const contactX = def.pos.x - dir * 30
|
|
await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
|
|
// Grab — both freeze
|
|
atk.play('special')
|
|
def.play('hit')
|
|
sfxClash()
|
|
k.shake(5)
|
|
await k.wait(0.1)
|
|
|
|
// Lift opponent overhead
|
|
const liftY = atk.pos.y - 80
|
|
sfxBoing()
|
|
await k.tween(def.pos.y, liftY, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(def.pos.x, atk.pos.x + dir * 5, 0.1, (v) => { def.pos.x = v })
|
|
// Spin overhead
|
|
const defOrigY = GROUND_Y
|
|
for (let i = 0; i < (isCritical ? 3 : 2); i++) {
|
|
await k.tween(def.pos.x, atk.pos.x - dir * 20, 0.06, (v) => { def.pos.x = v })
|
|
await k.tween(def.pos.x, atk.pos.x + dir * 20, 0.06, (v) => { def.pos.x = v })
|
|
}
|
|
|
|
// SLAM DOWN
|
|
sfxExplosion()
|
|
def.play('knockback')
|
|
const slamX = atk.pos.x + dir * 50
|
|
await Promise.all([
|
|
k.tween(def.pos.y, defOrigY, 0.08, (v) => { def.pos.y = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, slamX, 0.08, (v) => { def.pos.x = v }),
|
|
])
|
|
sfxCritical()
|
|
k.shake(isCritical ? 25 : 16)
|
|
screenFlash(isCritical ? '#ffffff' : '#ff6600', 0.15)
|
|
spawnSparks(slamX, defOrigY - 10, 18, '#ff6600')
|
|
spawnShockwave(slamX, GROUND_Y, '#ff6600')
|
|
spawnBulletHoles(slamX, GROUND_Y, 3)
|
|
await k.wait(0.2)
|
|
|
|
// Return
|
|
atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// PINBALL COMBO: bounce the opponent between walls/edges like a pinball
|
|
async function pinballCombo(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Initial launch punch
|
|
atk.play('attack')
|
|
sfxPunch()
|
|
const contactX = def.pos.x - dir * 35
|
|
await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
def.play('hit')
|
|
k.shake(8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 8, '#ff2d7b')
|
|
|
|
// Bounce the opponent back and forth across the screen
|
|
const bounces = isCritical ? 5 : 3
|
|
const leftWall = 30
|
|
const rightWall = W - 30
|
|
let defX = def.pos.x
|
|
for (let i = 0; i < bounces; i++) {
|
|
const toWall = i % 2 === (dir > 0 ? 0 : 1) ? rightWall : leftWall
|
|
sfxZoomWhoosh()
|
|
await k.tween(defX, toWall, 0.06, (v) => { def.pos.x = v }, k.easings.easeInQuad)
|
|
defX = toWall
|
|
// Wall hit
|
|
sfxBonk()
|
|
k.shake(6 + i * 2)
|
|
spawnSparks(toWall, def.pos.y - 20, 6, ['#ff2d7b', '#ffcc00', '#00f0ff'][i % 3])
|
|
screenFlash(['#ff2d7b', '#ffcc00', '#00f0ff'][i % 3], 0.05)
|
|
def.play(i % 2 === 0 ? 'hit' : 'knockback')
|
|
await k.wait(0.04)
|
|
}
|
|
|
|
// Final slam to ground
|
|
sfxCritical()
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 22 : 14)
|
|
screenFlash('#ffffff', 0.12)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 15, '#ffe14d')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d')
|
|
def.play('knockback')
|
|
await k.wait(0.2)
|
|
|
|
// Return
|
|
atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// SUPLEX: grab from behind, lift, and drive headfirst into ground
|
|
async function suplex(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Dash behind defender
|
|
sfxSpecial()
|
|
const behindX = def.pos.x + dir * 30
|
|
await k.tween(atk.pos.x, behindX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
// Flip attacker to face back
|
|
atk.scale.x = -atk.scale.x
|
|
atk.play('special')
|
|
sfxClash()
|
|
k.shake(4)
|
|
await k.wait(0.06)
|
|
|
|
// Grab and arc overhead
|
|
const arcCx = (atk.pos.x + def.pos.x) / 2
|
|
const arcTop = atk.pos.y - 120
|
|
// Both rise in an arc
|
|
const steps = 8
|
|
for (let i = 0; i <= steps; i++) {
|
|
const t = i / steps
|
|
const angle = Math.PI * t
|
|
def.pos.x = arcCx + Math.cos(angle) * 40
|
|
def.pos.y = GROUND_Y - Math.sin(angle) * 120
|
|
atk.pos.x = def.pos.x + dir * 20
|
|
atk.pos.y = def.pos.y + 10
|
|
await k.wait(0.02)
|
|
}
|
|
|
|
// IMPACT — headfirst into ground
|
|
sfxExplosion()
|
|
sfxCritical()
|
|
k.shake(isCritical ? 28 : 18)
|
|
screenFlash(isCritical ? '#ffffff' : '#ff2d2d', 0.2)
|
|
spawnSparks(def.pos.x, GROUND_Y - 10, 20, '#ff2d2d')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff2d2d')
|
|
def.play('ko')
|
|
await k.wait(0.15)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
|
|
// Flip attacker back
|
|
atk.scale.x = -atk.scale.x
|
|
atk.play('idle')
|
|
def.pos.y = GROUND_Y
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// ============================================================
|
|
// PIXEL PROP SYSTEM — draw recognizable objects from shapes
|
|
// ============================================================
|
|
|
|
type PropDef = { parts: { shape: 'rect' | 'circle'; x: number; y: number; w: number; h: number; color: string }[] }
|
|
|
|
const PROPS: Record<string, PropDef> = {
|
|
// Food
|
|
pizza: { parts: [
|
|
{ shape: 'rect', x: -7, y: -3, w: 14, h: 3, color: '#ffcc44' }, // crust
|
|
{ shape: 'rect', x: -6, y: 0, w: 12, h: 8, color: '#ff8800' }, // cheese
|
|
{ shape: 'rect', x: -5, y: 1, w: 10, h: 6, color: '#cc4400' }, // sauce
|
|
{ shape: 'circle', x: -3, y: 3, w: 3, h: 3, color: '#aa2200' }, // pepperoni
|
|
{ shape: 'circle', x: 2, y: 4, w: 3, h: 3, color: '#aa2200' }, // pepperoni
|
|
{ shape: 'circle', x: 0, y: 1, w: 2, h: 2, color: '#44aa22' }, // olive
|
|
]},
|
|
burger: { parts: [
|
|
{ shape: 'rect', x: -8, y: -4, w: 16, h: 4, color: '#cc8833' }, // top bun
|
|
{ shape: 'rect', x: -7, y: 0, w: 14, h: 3, color: '#44aa22' }, // lettuce
|
|
{ shape: 'rect', x: -7, y: 3, w: 14, h: 4, color: '#883311' }, // patty
|
|
{ shape: 'rect', x: -8, y: 7, w: 16, h: 3, color: '#cc8833' }, // bottom bun
|
|
{ shape: 'rect', x: -7, y: -1, w: 14, h: 1, color: '#ffcc00' }, // cheese
|
|
]},
|
|
hotdog: { parts: [
|
|
{ shape: 'rect', x: -10, y: -2, w: 20, h: 4, color: '#ffcc66' }, // bun
|
|
{ shape: 'rect', x: -9, y: -1, w: 18, h: 3, color: '#cc4422' }, // sausage
|
|
{ shape: 'rect', x: -8, y: -2, w: 16, h: 1, color: '#ffee00' }, // mustard
|
|
]},
|
|
banana: { parts: [
|
|
{ shape: 'rect', x: -3, y: -8, w: 5, h: 16, color: '#ffe135' },
|
|
{ shape: 'rect', x: -2, y: -9, w: 3, h: 2, color: '#88660d' }, // stem
|
|
{ shape: 'rect', x: -2, y: -7, w: 3, h: 14, color: '#ffee55' }, // highlight
|
|
]},
|
|
pie: { parts: [
|
|
{ shape: 'circle', x: 0, y: 0, w: 16, h: 16, color: '#f5deb3' }, // crust
|
|
{ shape: 'circle', x: 0, y: 0, w: 12, h: 12, color: '#ffffff' }, // cream
|
|
{ shape: 'circle', x: -2, y: -2, w: 3, h: 3, color: '#ff4444' }, // cherry
|
|
]},
|
|
// Weapons
|
|
gun: { parts: [
|
|
{ shape: 'rect', x: 0, y: -3, w: 24, h: 6, color: '#444444' }, // barrel
|
|
{ shape: 'rect', x: -4, y: -5, w: 12, h: 10, color: '#333333' }, // body
|
|
{ shape: 'rect', x: -2, y: 5, w: 6, h: 10, color: '#553322' }, // grip
|
|
{ shape: 'rect', x: 22, y: -2, w: 4, h: 4, color: '#555555' }, // muzzle
|
|
{ shape: 'rect', x: 6, y: -6, w: 8, h: 3, color: '#555555' }, // sight
|
|
]},
|
|
shotgun: { parts: [
|
|
{ shape: 'rect', x: 0, y: -2, w: 35, h: 5, color: '#333333' }, // barrel
|
|
{ shape: 'rect', x: 0, y: 3, w: 30, h: 4, color: '#333333' }, // under barrel
|
|
{ shape: 'rect', x: -6, y: -4, w: 10, h: 12, color: '#553322' }, // stock
|
|
{ shape: 'rect', x: -2, y: 7, w: 6, h: 8, color: '#664433' }, // grip
|
|
{ shape: 'rect', x: 33, y: -1, w: 4, h: 3, color: '#555555' }, // muzzle
|
|
]},
|
|
rocketLauncher: { parts: [
|
|
{ shape: 'rect', x: 0, y: -5, w: 30, h: 10, color: '#446633' }, // tube
|
|
{ shape: 'circle', x: 28, y: 0, w: 10, h: 10, color: '#335522' }, // end
|
|
{ shape: 'circle', x: 2, y: 0, w: 10, h: 10, color: '#335522' }, // back end
|
|
{ shape: 'rect', x: 8, y: 5, w: 6, h: 8, color: '#553322' }, // grip
|
|
{ shape: 'rect', x: 14, y: -8, w: 6, h: 4, color: '#555555' }, // sight
|
|
]},
|
|
minigun: { parts: [
|
|
{ shape: 'rect', x: 0, y: -6, w: 28, h: 3, color: '#444444' }, // barrel 1
|
|
{ shape: 'rect', x: 0, y: -2, w: 28, h: 3, color: '#555555' }, // barrel 2
|
|
{ shape: 'rect', x: 0, y: 2, w: 28, h: 3, color: '#444444' }, // barrel 3
|
|
{ shape: 'rect', x: -8, y: -8, w: 10, h: 16, color: '#333333' }, // housing
|
|
{ shape: 'rect', x: -4, y: 5, w: 6, h: 10, color: '#553322' }, // grip
|
|
{ shape: 'circle', x: 27, y: -2, w: 8, h: 8, color: '#666666' }, // muzzle ring
|
|
]},
|
|
sniper: { parts: [
|
|
{ shape: 'rect', x: 0, y: -1, w: 38, h: 3, color: '#333333' }, // barrel
|
|
{ shape: 'rect', x: -6, y: -4, w: 14, h: 8, color: '#444444' }, // body
|
|
{ shape: 'rect', x: -10, y: -3, w: 8, h: 4, color: '#553322' }, // stock
|
|
{ shape: 'circle', x: 12, y: -6, w: 6, h: 6, color: '#225588' }, // scope
|
|
{ shape: 'rect', x: 8, y: -7, w: 10, h: 2, color: '#555555' }, // scope mount
|
|
]},
|
|
katana: { parts: [
|
|
{ shape: 'rect', x: 0, y: -1, w: 40, h: 2, color: '#ccccdd' }, // blade
|
|
{ shape: 'rect', x: 38, y: -1, w: 4, h: 2, color: '#ffffff' }, // tip
|
|
{ shape: 'rect', x: -2, y: -3, w: 4, h: 6, color: '#ffcc00' }, // guard
|
|
{ shape: 'rect', x: -10, y: -2, w: 10, h: 4, color: '#442211' }, // handle
|
|
{ shape: 'rect', x: -8, y: -2, w: 2, h: 4, color: '#ddbb88' }, // wrap
|
|
{ shape: 'rect', x: -5, y: -2, w: 2, h: 4, color: '#ddbb88' }, // wrap
|
|
]},
|
|
// Vehicles (used as props in throws/drops)
|
|
car_mini: { parts: [
|
|
{ shape: 'rect', x: -12, y: -4, w: 24, h: 8, color: '#ff4444' }, // body
|
|
{ shape: 'rect', x: -6, y: -8, w: 12, h: 5, color: '#4488ff' }, // roof/windows
|
|
{ shape: 'circle', x: -8, y: 4, w: 6, h: 6, color: '#222222' }, // wheel
|
|
{ shape: 'circle', x: 8, y: 4, w: 6, h: 6, color: '#222222' }, // wheel
|
|
{ shape: 'rect', x: 11, y: -2, w: 3, h: 3, color: '#ffee00' }, // headlight
|
|
]},
|
|
tank_mini: { parts: [
|
|
{ shape: 'rect', x: -14, y: 0, w: 28, h: 8, color: '#556633' }, // hull
|
|
{ shape: 'rect', x: -8, y: -6, w: 16, h: 7, color: '#445522' }, // turret
|
|
{ shape: 'rect', x: 6, y: -5, w: 16, h: 3, color: '#333322' }, // barrel
|
|
{ shape: 'rect', x: -13, y: 7, w: 26, h: 4, color: '#333333' }, // tracks
|
|
{ shape: 'circle', x: -10, y: 9, w: 4, h: 4, color: '#444444' }, // wheel
|
|
{ shape: 'circle', x: 10, y: 9, w: 4, h: 4, color: '#444444' }, // wheel
|
|
]},
|
|
// Misc props
|
|
bomb: { parts: [
|
|
{ shape: 'circle', x: 0, y: 0, w: 14, h: 14, color: '#222222' },
|
|
{ shape: 'rect', x: -1, y: -9, w: 3, h: 5, color: '#888888' }, // fuse
|
|
{ shape: 'circle', x: 0, y: -10, w: 4, h: 4, color: '#ff6600' }, // spark
|
|
]},
|
|
shield: { parts: [
|
|
{ shape: 'rect', x: -10, y: -14, w: 20, h: 28, color: '#4466aa' },
|
|
{ shape: 'rect', x: -8, y: -12, w: 16, h: 24, color: '#5577bb' },
|
|
{ shape: 'rect', x: -3, y: -8, w: 6, h: 16, color: '#ffcc00' }, // emblem cross
|
|
{ shape: 'rect', x: -7, y: -3, w: 14, h: 6, color: '#ffcc00' }, // emblem cross
|
|
{ shape: 'rect', x: -10, y: -14, w: 20, h: 2, color: '#6688cc' }, // top edge
|
|
]},
|
|
soccerball: { parts: [
|
|
{ shape: 'circle', x: 0, y: 0, w: 12, h: 12, color: '#ffffff' },
|
|
{ shape: 'rect', x: -3, y: -3, w: 6, h: 6, color: '#222222' }, // pentagon
|
|
]},
|
|
donut: { parts: [
|
|
{ shape: 'circle', x: 0, y: 0, w: 14, h: 14, color: '#ff88cc' }, // icing
|
|
{ shape: 'circle', x: 0, y: 0, w: 6, h: 6, color: '#cc8833' }, // hole (body color)
|
|
{ shape: 'circle', x: -3, y: -3, w: 2, h: 2, color: '#ff4444' }, // sprinkle
|
|
{ shape: 'circle', x: 3, y: -2, w: 2, h: 2, color: '#44ff44' }, // sprinkle
|
|
{ shape: 'circle', x: 1, y: 4, w: 2, h: 2, color: '#ffff00' }, // sprinkle
|
|
]},
|
|
watermelon: { parts: [
|
|
{ shape: 'circle', x: 0, y: 0, w: 18, h: 18, color: '#33aa33' }, // rind
|
|
{ shape: 'circle', x: 0, y: 0, w: 14, h: 14, color: '#ff4466' }, // flesh
|
|
{ shape: 'circle', x: -2, y: -1, w: 2, h: 2, color: '#222222' }, // seed
|
|
{ shape: 'circle', x: 3, y: 2, w: 2, h: 2, color: '#222222' }, // seed
|
|
{ shape: 'circle', x: 0, y: 4, w: 2, h: 2, color: '#222222' }, // seed
|
|
]},
|
|
chainsaw: { parts: [
|
|
{ shape: 'rect', x: 0, y: -2, w: 28, h: 5, color: '#888888' }, // blade
|
|
{ shape: 'rect', x: -8, y: -5, w: 12, h: 10, color: '#ff6600' }, // body
|
|
{ shape: 'rect', x: -4, y: 5, w: 6, h: 7, color: '#553322' }, // grip
|
|
{ shape: 'rect', x: 26, y: -1, w: 4, h: 3, color: '#aaaaaa' }, // tip
|
|
{ shape: 'rect', x: 0, y: -3, w: 26, h: 1, color: '#666666' }, // chain top
|
|
{ shape: 'rect', x: 0, y: 3, w: 26, h: 1, color: '#666666' }, // chain bottom
|
|
]},
|
|
// Fatality props
|
|
rubber_chicken: { parts: [
|
|
{ shape: 'rect', x: -2, y: -12, w: 5, h: 20, color: '#ffdd44' }, // body
|
|
{ shape: 'circle', x: 0, y: -14, w: 8, h: 8, color: '#ffee55' }, // head
|
|
{ shape: 'rect', x: 2, y: -16, w: 8, h: 3, color: '#ff8800' }, // beak
|
|
{ shape: 'circle', x: -1, y: -15, w: 2, h: 2, color: '#000000' }, // eye
|
|
{ shape: 'rect', x: -1, y: 6, w: 3, h: 5, color: '#ff6600' }, // leg
|
|
]},
|
|
anvil: { parts: [
|
|
{ shape: 'rect', x: -14, y: -4, w: 28, h: 12, color: '#555555' }, // body
|
|
{ shape: 'rect', x: -18, y: 6, w: 36, h: 5, color: '#444444' }, // base
|
|
{ shape: 'rect', x: -10, y: -8, w: 20, h: 5, color: '#666666' }, // top
|
|
{ shape: 'rect', x: 8, y: -12, w: 6, h: 5, color: '#777777' }, // horn
|
|
]},
|
|
flyswatter: { parts: [
|
|
{ shape: 'rect', x: -2, y: 0, w: 4, h: 35, color: '#cc8833' }, // handle
|
|
{ shape: 'rect', x: -12, y: -14, w: 24, h: 16, color: '#44aa44' }, // paddle
|
|
{ shape: 'rect', x: -10, y: -12, w: 20, h: 12, color: '#338833' }, // grid
|
|
]},
|
|
toilet: { parts: [
|
|
{ shape: 'rect', x: -12, y: -4, w: 24, h: 16, color: '#eeeeee' }, // bowl
|
|
{ shape: 'rect', x: -14, y: -14, w: 28, h: 12, color: '#dddddd' }, // tank
|
|
{ shape: 'circle', x: 0, y: 4, w: 16, h: 16, color: '#aaddff' }, // water
|
|
{ shape: 'rect', x: -12, y: -5, w: 24, h: 3, color: '#cccccc' }, // seat
|
|
{ shape: 'rect', x: 8, y: -12, w: 4, h: 6, color: '#888888' }, // handle
|
|
]},
|
|
keyboard: { parts: [
|
|
{ shape: 'rect', x: -20, y: -3, w: 40, h: 8, color: '#333333' }, // base
|
|
{ shape: 'rect', x: -18, y: -2, w: 36, h: 6, color: '#444444' }, // keys area
|
|
{ shape: 'rect', x: -16, y: -1, w: 4, h: 3, color: '#666666' }, // key
|
|
{ shape: 'rect', x: -10, y: -1, w: 4, h: 3, color: '#666666' }, // key
|
|
{ shape: 'rect', x: -4, y: -1, w: 4, h: 3, color: '#666666' }, // key
|
|
{ shape: 'rect', x: 2, y: -1, w: 12, h: 3, color: '#666666' }, // spacebar
|
|
]},
|
|
disco_ball: { parts: [
|
|
{ shape: 'circle', x: 0, y: 0, w: 18, h: 18, color: '#cccccc' }, // ball
|
|
{ shape: 'rect', x: -1, y: -12, w: 2, h: 5, color: '#888888' }, // chain
|
|
{ shape: 'rect', x: -3, y: -2, w: 3, h: 3, color: '#ffffff' }, // mirror
|
|
{ shape: 'rect', x: 2, y: 1, w: 3, h: 3, color: '#eeeeee' }, // mirror
|
|
{ shape: 'rect', x: -1, y: 4, w: 3, h: 3, color: '#ffffff' }, // mirror
|
|
]},
|
|
rubber_duck: { parts: [
|
|
{ shape: 'circle', x: 0, y: 0, w: 10, h: 10, color: '#ffdd00' }, // body
|
|
{ shape: 'circle', x: 3, y: -5, w: 7, h: 7, color: '#ffee22' }, // head
|
|
{ shape: 'rect', x: 5, y: -6, w: 5, h: 2, color: '#ff8800' }, // beak
|
|
{ shape: 'circle', x: 4, y: -6, w: 2, h: 2, color: '#000000' }, // eye
|
|
]},
|
|
baguette: { parts: [
|
|
{ shape: 'rect', x: -16, y: -3, w: 32, h: 6, color: '#cc9944' }, // bread
|
|
{ shape: 'rect', x: -14, y: -2, w: 28, h: 4, color: '#ddaa55' }, // crust highlight
|
|
{ shape: 'rect', x: -8, y: -4, w: 4, h: 1, color: '#bb8833' }, // score
|
|
{ shape: 'rect', x: 2, y: -4, w: 4, h: 1, color: '#bb8833' }, // score
|
|
]},
|
|
boot: { parts: [
|
|
{ shape: 'rect', x: -8, y: -16, w: 14, h: 24, color: '#553322' }, // shaft
|
|
{ shape: 'rect', x: -8, y: 6, w: 22, h: 8, color: '#442211' }, // sole
|
|
{ shape: 'rect', x: -6, y: -14, w: 10, h: 4, color: '#664433' }, // top band
|
|
{ shape: 'rect', x: 12, y: 4, w: 4, h: 6, color: '#442211' }, // toe
|
|
]},
|
|
cannon: { parts: [
|
|
{ shape: 'rect', x: -10, y: -6, w: 28, h: 12, color: '#444444' }, // barrel
|
|
{ shape: 'circle', x: -10, y: 0, w: 14, h: 14, color: '#333333' }, // base
|
|
{ shape: 'circle', x: 16, y: 0, w: 10, h: 10, color: '#555555' }, // muzzle
|
|
{ shape: 'rect', x: -6, y: 6, w: 6, h: 8, color: '#664433' }, // wheel
|
|
]},
|
|
stamp: { parts: [
|
|
{ shape: 'rect', x: -3, y: -18, w: 6, h: 14, color: '#884422' }, // handle
|
|
{ shape: 'rect', x: -14, y: -6, w: 28, h: 8, color: '#cc4444' }, // stamp face
|
|
{ shape: 'rect', x: -12, y: -4, w: 24, h: 4, color: '#aa2222' }, // ink
|
|
]},
|
|
vending_machine: { parts: [
|
|
{ shape: 'rect', x: -12, y: -18, w: 24, h: 36, color: '#cc3333' }, // body
|
|
{ shape: 'rect', x: -10, y: -14, w: 12, h: 20, color: '#222222' }, // window
|
|
{ shape: 'rect', x: 3, y: -8, w: 6, h: 4, color: '#44aa44' }, // button
|
|
{ shape: 'rect', x: 3, y: 0, w: 6, h: 4, color: '#4444aa' }, // button
|
|
{ shape: 'rect', x: -8, y: 10, w: 10, h: 5, color: '#333333' }, // dispenser
|
|
]},
|
|
lawn_mower: { parts: [
|
|
{ shape: 'rect', x: -10, y: -2, w: 20, h: 8, color: '#44aa44' }, // body
|
|
{ shape: 'circle', x: -8, y: 6, w: 6, h: 6, color: '#333333' }, // wheel
|
|
{ shape: 'circle', x: 8, y: 6, w: 6, h: 6, color: '#333333' }, // wheel
|
|
{ shape: 'rect', x: -2, y: -12, w: 3, h: 12, color: '#888888' }, // handle
|
|
{ shape: 'rect', x: -4, y: -14, w: 8, h: 3, color: '#666666' }, // grip
|
|
]},
|
|
}
|
|
|
|
// Spawn a multi-part pixel prop at position, returns array of game objects
|
|
function spawnWeaponProp(propName: string, x: number, y: number, flipX: boolean = false, zIndex: number = 16): any[] {
|
|
const def = PROPS[propName]
|
|
if (!def) {
|
|
// Fallback: single colored circle
|
|
return [k.add([k.circle(8), k.pos(x, y), k.color(k.Color.fromHex('#ff8800')), k.opacity(1), k.z(zIndex), k.anchor('center')])]
|
|
}
|
|
const objs: any[] = []
|
|
for (const p of def.parts) {
|
|
const px = flipX ? x - p.x : x + p.x
|
|
const obj = k.add([
|
|
p.shape === 'circle' ? k.circle(p.w / 2) : k.rect(p.w, p.h),
|
|
k.pos(px, y + p.y),
|
|
k.color(k.Color.fromHex(p.color)),
|
|
k.opacity(1),
|
|
k.z(zIndex),
|
|
k.anchor('center'),
|
|
])
|
|
objs.push(obj)
|
|
}
|
|
return objs
|
|
}
|
|
|
|
// Move all parts of a prop to a new position (relative to anchor)
|
|
function moveProp(objs: any[], def: PropDef, x: number, y: number, flipX: boolean = false) {
|
|
for (let i = 0; i < objs.length && i < def.parts.length; i++) {
|
|
const p = def.parts[i]
|
|
objs[i].pos.x = flipX ? x - p.x : x + p.x
|
|
objs[i].pos.y = y + p.y
|
|
}
|
|
}
|
|
|
|
function destroyProp(objs: any[]) {
|
|
objs.forEach(o => { if (o.exists()) o.destroy() })
|
|
}
|
|
|
|
// Map choreography names to prop types for the factories
|
|
const CHOREO_PROPS: Record<string, string> = {
|
|
pizzaSlam: 'pizza', burgerToss: 'burger', hotdogWhip: 'hotdog', bananaFling: 'banana',
|
|
pieSmash: 'pie', watermelonBomb: 'watermelon', donutBarrage: 'donut',
|
|
sushiBarrage: 'pizza', tacoStorm: 'pizza', iceCreamFling: 'donut',
|
|
bombThrow: 'bomb', grenadeBlast: 'bomb', dynamiteBlast: 'bomb',
|
|
bowlingBallRoll: 'soccerball', soccerKick: 'soccerball', basketballDunk: 'soccerball',
|
|
}
|
|
|
|
// Map choreography names to weapon props the attacker holds
|
|
const CHOREO_WEAPON: Record<string, string> = {
|
|
gunBurst: 'gun', sniperShot: 'sniper', minigunSpray: 'minigun',
|
|
rocketLauncher: 'rocketLauncher', pocketCannon: 'shotgun',
|
|
chainsawRev: 'chainsaw', katanaCombo: 'katana', swordSlash: 'katana',
|
|
}
|
|
|
|
// === PER-CHARACTER WEAPON SYSTEM ===
|
|
// Each bot gets 3 special weapons based on their seed, balanced frequency
|
|
|
|
interface BotWeapons {
|
|
melee: string // close range weapon prop
|
|
ranged: string // gun/launcher prop
|
|
thrown: string // throwable prop
|
|
}
|
|
|
|
const MELEE_WEAPONS = ['katana', 'chainsaw', 'shield']
|
|
const RANGED_WEAPONS = ['gun', 'shotgun', 'sniper', 'minigun', 'rocketLauncher']
|
|
const THROWN_WEAPONS = ['pizza', 'burger', 'bomb', 'watermelon', 'donut', 'soccerball', 'banana']
|
|
|
|
function getBotWeapons(seed: string): BotWeapons {
|
|
let h = 0
|
|
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
|
|
return {
|
|
melee: MELEE_WEAPONS[Math.abs(h) % MELEE_WEAPONS.length],
|
|
ranged: RANGED_WEAPONS[Math.abs(h >> 3) % RANGED_WEAPONS.length],
|
|
thrown: THROWN_WEAPONS[Math.abs(h >> 7) % THROWN_WEAPONS.length],
|
|
}
|
|
}
|
|
|
|
const weaponsA = getBotWeapons(botA.seed)
|
|
const weaponsB = getBotWeapons(botB.seed)
|
|
|
|
// === DEFENSE / SHIELD SYSTEM ===
|
|
|
|
async function playBlock(side: 'a' | 'b') {
|
|
const defender = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
if (!defender) return
|
|
const dir = side === 'a' ? 1 : -1
|
|
// Spawn shield in front
|
|
const shieldObjs = spawnWeaponProp('shield', defender.pos.x + dir * 20, defender.pos.y - 25, side === 'b', 19)
|
|
sfxBlock()
|
|
// Flash shield
|
|
shieldObjs.forEach(o => { o.opacity = 0.9 })
|
|
await k.wait(0.15)
|
|
// Impact sparks
|
|
spawnSparks(defender.pos.x + dir * 25, defender.pos.y - 25, 6, '#4488ff')
|
|
k.shake(3)
|
|
await k.wait(0.3)
|
|
// Shield fades
|
|
shieldObjs.forEach(o => {
|
|
k.tween(o.opacity, 0, 0.2, (v: number) => { o.opacity = v }).then(() => { if (o.exists()) o.destroy() })
|
|
})
|
|
await k.wait(0.1)
|
|
}
|
|
|
|
// Spawn attacker's signature weapon as a visual during choreographies
|
|
function getAttackerWeapon(side: 'a' | 'b', type: 'melee' | 'ranged' | 'thrown'): string {
|
|
const weapons = side === 'a' ? weaponsA : weaponsB
|
|
return weapons[type]
|
|
}
|
|
|
|
// ============================================================
|
|
// CHOREOGRAPHY FACTORIES — generate moves from templates
|
|
// ============================================================
|
|
type ChoreoFn = typeof dashPunch
|
|
|
|
// FACTORY 1: Throw object(s) in an arc at the opponent — NOW WITH PIXEL PROPS
|
|
function makeThrow(color: string, size: number, count: number = 1, arc: number = 60, circle: boolean = false, trail?: string): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial(); await k.wait(0.12)
|
|
const total = count + (isCritical ? 2 : 0)
|
|
for (let i = 0; i < total; i++) {
|
|
const fx = atk.pos.x + dir * 25, fy = atk.pos.y - 35
|
|
const tx = def.pos.x + (Math.random() - 0.5) * 30, ty = def.pos.y - 25 + (Math.random() - 0.5) * 20
|
|
|
|
// Try to find a prop for this choreography, fall back to colored shape
|
|
const p = k.add([circle ? k.circle(size / 2) : k.rect(size, size), k.pos(fx, fy), k.color(k.Color.fromHex(color)), k.opacity(1), k.z(16), k.rotate(Math.random() * 360), k.anchor('center')])
|
|
k.tween(0, 1, 0.18, (t) => { p.pos.x = fx + (tx - fx) * t; p.pos.y = fy + (ty - fy) * t - Math.sin(t * Math.PI) * arc; p.angle += 720 * k.dt() }).then(() => { p.destroy(); spawnSparks(tx, ty, 3, trail || color) })
|
|
if (i < total - 1) await k.wait(0.05)
|
|
}
|
|
await k.wait(0.12); sfxBonk()
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 14 : 6)
|
|
if (isCritical) { screenFlash(color); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 14 : 7, color)
|
|
const push = dir * (isCritical ? 85 : 38)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 1b: Throw with detailed pixel prop
|
|
function makeThrowProp(propName: string, count: number = 1, arc: number = 60, trail: string = '#ffcc00'): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial(); await k.wait(0.12)
|
|
const total = count + (isCritical ? 2 : 0)
|
|
const propDef = PROPS[propName]
|
|
for (let i = 0; i < total; i++) {
|
|
const fx = atk.pos.x + dir * 25, fy = atk.pos.y - 35
|
|
const tx = def.pos.x + (Math.random() - 0.5) * 30, ty = def.pos.y - 25 + (Math.random() - 0.5) * 20
|
|
const objs = spawnWeaponProp(propName, fx, fy, dir < 0)
|
|
k.tween(0, 1, 0.2, (t) => {
|
|
const cx = fx + (tx - fx) * t
|
|
const cy = fy + (ty - fy) * t - Math.sin(t * Math.PI) * arc
|
|
if (propDef) moveProp(objs, propDef, cx, cy, dir < 0)
|
|
else if (objs[0]) { objs[0].pos.x = cx; objs[0].pos.y = cy }
|
|
}).then(() => { destroyProp(objs); spawnSparks(tx, ty, 5, trail) })
|
|
if (i < total - 1) await k.wait(0.06)
|
|
}
|
|
await k.wait(0.15); sfxBonk()
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 14 : 6)
|
|
if (isCritical) { screenFlash(trail); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 14 : 7, trail)
|
|
const push = dir * (isCritical ? 85 : 38)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 1c: Ranged attack with visible pixel gun prop
|
|
function makeGunProp(propName: string, bulletCount: number = 4, bulletColor: string = '#ffee00'): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial()
|
|
// Spawn gun prop on attacker
|
|
const gx = atk.pos.x + dir * 15, gy = atk.pos.y - 35
|
|
const gunObjs = spawnWeaponProp(propName, gx, gy, dir < 0, 18)
|
|
const gunDef = PROPS[propName]
|
|
await k.wait(0.15)
|
|
// Fire bullets
|
|
const total = bulletCount + (isCritical ? 3 : 0)
|
|
for (let i = 0; i < total; i++) {
|
|
sfxGunshot()
|
|
// Muzzle flash
|
|
const mfx = gx + dir * 30, mfy = gy
|
|
const flash = k.add([k.circle(5 + Math.random() * 4), k.pos(mfx, mfy), k.color(k.Color.fromHex(bulletColor)), k.opacity(0.9), k.z(19), k.anchor('center')])
|
|
setTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
|
|
// Bullet trail
|
|
const targetY = def.pos.y - 20 - Math.random() * 30
|
|
await spawnProjectile(mfx, mfy, def.pos.x, targetY, bulletColor, 4)
|
|
sfxBulletHit()
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 20, targetY, 3, bulletColor)
|
|
// Recoil — bounce gun
|
|
if (gunDef) {
|
|
const rx = gx - dir * 4
|
|
moveProp(gunObjs, gunDef, rx, gy, dir < 0)
|
|
await k.wait(0.02)
|
|
moveProp(gunObjs, gunDef, gx, gy, dir < 0)
|
|
}
|
|
def.opacity = 0.5; await k.wait(0.03); def.opacity = 1
|
|
}
|
|
spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 5 : 3)
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8)
|
|
if (isCritical) { screenFlash(bulletColor); sfxCritical(); sfxExplosion() }
|
|
// Remove gun
|
|
destroyProp(gunObjs)
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 2: Swing weapon — run up, swing rect, hit
|
|
function makeSwing(weaponW: number, weaponH: number, weaponColor: string, headColor?: string, headW?: number, headH?: number): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
const cx = origDX - dir * 55
|
|
await k.tween(atk.pos.x, cx, 0.14, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
const wx = atk.pos.x + dir * 18, wy = atk.pos.y - 55
|
|
const shaft = k.add([k.rect(weaponW, weaponH), k.pos(wx, wy), k.color(safeColor(k,weaponColor)), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -40 : 40)])
|
|
let head: any = null
|
|
if (headColor) { head = k.add([k.rect(headW || 18, headH || 14), k.pos(wx + dir * 10, wy - weaponH + 5), k.color(safeColor(k,headColor)), k.z(18)]) }
|
|
atk.play('attack'); sfxSpecial()
|
|
await k.tween(dir === 1 ? -40 : 40, dir === 1 ? 85 : -85, 0.13, (v) => { shaft.angle = v }, k.easings.easeInQuad)
|
|
sfxBonk(); shaft.destroy(); if (head) head.destroy()
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 18 : 9)
|
|
if (isCritical) { screenFlash(weaponColor); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 14 : 7, headColor || weaponColor)
|
|
const push = dir * (isCritical ? 100 : 48)
|
|
k.tween(def.pos.x, origDX + push, 0.22, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
}
|
|
|
|
// FACTORY 3: Drop something from the sky onto opponent
|
|
function makeDrop(w: number, h: number, color: string, bounceColor?: string, circle: boolean = false): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial(); await k.wait(0.12)
|
|
const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(safeColor(k,'#000000')), k.opacity(0.3), k.z(4)])
|
|
k.tween(5, w * 0.7, 0.25, (v) => { shadow.radius = v })
|
|
const obj = k.add([circle ? k.circle(w / 2) : k.rect(w, h), k.pos(def.pos.x - (circle ? 0 : w / 2), -h - 10), k.color(safeColor(k,color)), k.opacity(0.9), k.z(18)])
|
|
sfxSlideDown()
|
|
await k.tween(-h - 10, def.pos.y - 45, 0.22, (v) => { obj.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); sfxExplosion()
|
|
k.shake(isCritical ? 22 : 13); screenFlash(isCritical ? '#ffffff' : (bounceColor || color), 0.15)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 14, bounceColor || color)
|
|
spawnShockwave(def.pos.x, GROUND_Y, bounceColor || color)
|
|
shadow.destroy(); def.play(isCritical ? 'knockback' : 'hit')
|
|
await k.tween(obj.pos.y, obj.pos.y - 35, 0.08, (v) => { obj.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(obj.pos.y, obj.pos.y + 35, 0.08, (v) => { obj.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); await k.wait(0.1)
|
|
k.tween(0.9, 0, 0.25, (v) => { obj.opacity = v }).then(() => obj.destroy())
|
|
const push = dir * (isCritical ? 95 : 42)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.25); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 4: Vehicle drives across screen (like motorbikeCharge/carSmash)
|
|
function makeVehicle(bodyW: number, bodyH: number, bodyColor: string, wheelR: number = 8, parts?: { w: number, h: number, color: string, ox: number, oy: number }[]): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
const startX = dir === 1 ? -bodyW - 20 : W + bodyW + 20
|
|
atk.opacity = 0; atk.pos.x = startX
|
|
const vY = GROUND_Y - bodyH / 2 - wheelR
|
|
const body = k.add([k.rect(bodyW, bodyH), k.pos(startX, vY), k.color(safeColor(k,isCritical ? '#ff2d2d' : bodyColor)), k.z(16)])
|
|
const w1 = k.add([k.circle(wheelR), k.pos(startX + dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(safeColor(k,'#222222')), k.z(16)])
|
|
const w2 = k.add([k.circle(wheelR), k.pos(startX - dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(safeColor(k,'#222222')), k.z(16)])
|
|
const extras: any[] = []
|
|
if (parts) for (const p of parts) {
|
|
extras.push(k.add([k.rect(p.w, p.h), k.pos(startX + p.ox * dir, vY + p.oy), k.color(safeColor(k,p.color)), k.z(17)]))
|
|
}
|
|
sfxJetpack()
|
|
const exh = setInterval(() => {
|
|
const ep = k.add([k.circle(3), k.pos(body.pos.x - dir * bodyW / 2, vY + 5), k.color(safeColor(k,'#888888')), k.opacity(0.5), k.z(4)])
|
|
ep.onUpdate(() => { ep.opacity -= 2 * k.dt(); if (ep.opacity <= 0) ep.destroy() })
|
|
}, 30)
|
|
const tgt = origDX + dir * 20
|
|
await k.tween(startX, tgt, 0.32, (v) => {
|
|
body.pos.x = v; w1.pos.x = v + dir * (bodyW / 3); w2.pos.x = v - dir * (bodyW / 3)
|
|
extras.forEach((e, i) => { if (parts) e.pos.x = v + parts[i].ox * dir })
|
|
}, k.easings.easeInQuad)
|
|
clearInterval(exh)
|
|
sfxExplosion(); sfxCritical()
|
|
body.destroy(); w1.destroy(); w2.destroy(); extras.forEach(e => e.destroy())
|
|
k.shake(isCritical ? 25 : 14); screenFlash(isCritical ? '#ff2d2d' : bodyColor, 0.18)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 18, '#ff6600'); spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
def.play('knockback')
|
|
const push = dir * (isCritical ? 150 : 70)
|
|
const origDY = def.pos.y
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.y, origDY - 90, 0.14, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(def.pos.y, origDY, 0.18, (v) => { def.pos.y = v }, k.easings.easeInQuad))
|
|
])
|
|
atk.pos.x = origAX; atk.opacity = 1; atk.play('idle')
|
|
await k.wait(0.15)
|
|
await k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 5: Beam/ray attack — attacker fires continuous beam
|
|
function makeBeam(color: string, width: number = 6, pulses: number = 1): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial()
|
|
// Charge up
|
|
const chargeOrb = k.add([k.circle(3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 35), k.color(safeColor(k,color)), k.opacity(0.8), k.z(18)])
|
|
await k.tween(3, 12 + (isCritical ? 5 : 0), 0.2, (v) => { chargeOrb.radius = v }, k.easings.easeOutQuad)
|
|
chargeOrb.destroy()
|
|
// Fire beam
|
|
const total = pulses + (isCritical ? 1 : 0)
|
|
for (let p = 0; p < total; p++) {
|
|
sfxZap()
|
|
const bx = atk.pos.x + dir * 30, by = atk.pos.y - 35
|
|
const beamLen = Math.abs(def.pos.x - atk.pos.x) + 30
|
|
const beam = k.add([k.rect(beamLen, width + (isCritical ? 4 : 0)), k.pos(bx, by - width / 2), k.color(safeColor(k,color)), k.opacity(0.9), k.z(17)])
|
|
const glow = k.add([k.rect(beamLen, width * 2.5), k.pos(bx, by - width * 1.25), k.color(safeColor(k,color)), k.opacity(0.25), k.z(16)])
|
|
if (dir === -1) { beam.pos.x = bx - beamLen; glow.pos.x = bx - beamLen }
|
|
await k.wait(0.08)
|
|
def.play('hit'); k.shake(isCritical ? 12 : 5)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 8, color)
|
|
await k.wait(0.08)
|
|
k.tween(0.9, 0, 0.12, (v) => { beam.opacity = v; glow.opacity = v * 0.3 }).then(() => { beam.destroy(); glow.destroy() })
|
|
if (p < total - 1) await k.wait(0.06)
|
|
}
|
|
if (isCritical) { screenFlash(color); sfxCritical() }
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 7)
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 6: Spin/flip — attacker does acrobatic motion then strikes
|
|
function makeSpin(spinType: 'horizontal' | 'vertical' | 'corkscrew', height: number = 120, hits: number = 1): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
const origAY = atk.pos.y
|
|
const cx = origDX - dir * 35
|
|
atk.play('kick'); sfxKick()
|
|
if (spinType === 'vertical') {
|
|
// Frontflip / backflip — jump up, arc to opponent
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, cx, 0.25, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(origAY, origAY - height, 0.12, (v) => { atk.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(origAY - height, origAY, 0.13, (v) => { atk.pos.y = v }, k.easings.easeInQuad)),
|
|
k.tween(0, 360 * (isCritical ? 2 : 1), 0.25, (v) => { atk.angle = v }).then(() => { atk.angle = 0 }),
|
|
])
|
|
} else if (spinType === 'corkscrew') {
|
|
// Corkscrew — spiral path
|
|
await k.tween(0, 1, 0.28, (t) => {
|
|
atk.pos.x = origAX + (cx - origAX) * t
|
|
atk.pos.y = origAY - Math.sin(t * Math.PI * 3) * height * 0.4
|
|
atk.angle = t * 720
|
|
}, k.easings.easeInOutQuad)
|
|
atk.angle = 0
|
|
} else {
|
|
// Horizontal tornado spin
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, cx, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(0, 720 * (isCritical ? 1.5 : 1), 0.2, (v) => { atk.angle = v }).then(() => { atk.angle = 0 }),
|
|
])
|
|
}
|
|
// Hit(s)
|
|
const hc = hits + (isCritical ? 1 : 0)
|
|
for (let i = 0; i < hc; i++) {
|
|
sfxPunch(); def.play('hit'); k.shake(4 + i * 2)
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 20, def.pos.y - 25, 5, theme.accent)
|
|
def.pos.x += dir * 10
|
|
if (i < hc - 1) await k.wait(0.06)
|
|
}
|
|
if (isCritical) { screenFlash(theme.accent); sfxCritical() }
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8)
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
}
|
|
|
|
// FACTORY 7: Summon/swarm — creatures fly from behind attacker toward defender
|
|
function makeSwarm(color: string, count: number, size: number, speed: number = 400, zigzag: boolean = false): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial(); await k.wait(0.15)
|
|
const total = count + (isCritical ? 3 : 0)
|
|
const swarmThings: any[] = []
|
|
for (let i = 0; i < total; i++) {
|
|
const sx = atk.pos.x - dir * 20 + (Math.random() - 0.5) * 30
|
|
const sy = atk.pos.y - 40 + (Math.random() - 0.5) * 50
|
|
const p = k.add([k.circle(size / 2 + Math.random() * 2), k.pos(sx, sy), k.color(safeColor(k,color)), k.opacity(0.9), k.z(16)])
|
|
const tx = def.pos.x + (Math.random() - 0.5) * 25, ty = def.pos.y - 30 + (Math.random() - 0.5) * 30
|
|
swarmThings.push(p)
|
|
k.tween(0, 1, (Math.abs(tx - sx) / speed) + Math.random() * 0.1, (t) => {
|
|
p.pos.x = sx + (tx - sx) * t
|
|
p.pos.y = sy + (ty - sy) * t + (zigzag ? Math.sin(t * Math.PI * 6) * 15 : 0)
|
|
}).then(() => { p.destroy(); spawnSparks(tx, ty, 2, color) })
|
|
await k.wait(0.03)
|
|
}
|
|
await k.wait(0.2)
|
|
sfxBonk(); def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8)
|
|
if (isCritical) { screenFlash(color); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 10, color)
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
swarmThings.forEach(s => { if (s.exists()) s.destroy() })
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 8: Explosion — thing appears near opponent and explodes
|
|
function makeExplosion(objW: number, objH: number, objColor: string, fuseTime: number = 0.3, blastColor: string = '#ff6600', circle: boolean = false): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial()
|
|
// Throw the thing
|
|
const fromX = atk.pos.x + dir * 25, fromY = atk.pos.y - 35
|
|
const toX = def.pos.x, toY = def.pos.y - 20
|
|
const obj = k.add([circle ? k.circle(objW / 2) : k.rect(objW, objH), k.pos(fromX, fromY), k.color(safeColor(k,objColor)), k.opacity(1), k.z(16), k.rotate(0)])
|
|
await k.tween(0, 1, 0.2, (t) => {
|
|
obj.pos.x = fromX + (toX - fromX) * t
|
|
obj.pos.y = fromY + (toY - fromY) * t - Math.sin(t * Math.PI) * 80
|
|
obj.angle = t * 360
|
|
})
|
|
obj.angle = 0
|
|
// Fuse / flash
|
|
for (let i = 0; i < 3; i++) {
|
|
obj.opacity = 0.4; await k.wait(fuseTime / 6); obj.opacity = 1; await k.wait(fuseTime / 6)
|
|
}
|
|
// BOOM
|
|
obj.destroy(); sfxExplosion()
|
|
const blastR = isCritical ? 45 : 28
|
|
const blast = k.add([k.circle(blastR), k.pos(toX, toY), k.color(safeColor(k,blastColor)), k.opacity(0.8), k.z(19)])
|
|
k.tween(blastR, blastR * 2.5, 0.15, (v) => { blast.radius = v })
|
|
k.tween(0.8, 0, 0.2, (v) => { blast.opacity = v }).then(() => blast.destroy())
|
|
k.shake(isCritical ? 25 : 14); screenFlash(blastColor, 0.15)
|
|
spawnSparks(toX, toY, isCritical ? 25 : 12, blastColor); spawnShockwave(toX, GROUND_Y, blastColor)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
if (isCritical) sfxCritical()
|
|
const push = dir * (isCritical ? 120 : 55)
|
|
const origDY = def.pos.y
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(origDY, origDY - 60, 0.12, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(origDY - 60, origDY, 0.15, (v) => { def.pos.y = v }, k.easings.easeInQuad))
|
|
])
|
|
await k.wait(0.15); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
}
|
|
|
|
// FACTORY 9: Grab/wrestling — run up, grab, slam
|
|
function makeGrab(slamType: 'up' | 'down' | 'spin' | 'toss', height: number = 150): ChoreoFn {
|
|
return async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
const cx = origDX - dir * 30
|
|
await k.tween(atk.pos.x, cx, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('attack'); sfxPunch(); await k.wait(0.08)
|
|
// Grab
|
|
def.play('hit'); sfxSpecial()
|
|
const origDY = def.pos.y
|
|
if (slamType === 'up') {
|
|
// Lift and slam down
|
|
await k.tween(origDY, origDY - height, 0.2, (v) => { def.pos.y = v; atk.pos.y = v + 20 }, k.easings.easeOutQuad)
|
|
await k.wait(0.1)
|
|
await k.tween(origDY - height, origDY, 0.15, (v) => { def.pos.y = v; atk.pos.y = GROUND_Y }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(isCritical ? 22 : 12); spawnShockwave(def.pos.x, GROUND_Y, theme.accent)
|
|
} else if (slamType === 'down') {
|
|
// Piledriver — both go up, come down head first
|
|
await Promise.all([
|
|
k.tween(origDY, origDY - height, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad),
|
|
k.tween(atk.pos.y, atk.pos.y - height, 0.2, (v) => { atk.pos.y = v }, k.easings.easeOutQuad),
|
|
])
|
|
await k.wait(0.1)
|
|
def.angle = 180
|
|
await Promise.all([
|
|
k.tween(origDY - height, origDY, 0.13, (v) => { def.pos.y = v }, k.easings.easeInQuad),
|
|
k.tween(atk.pos.y, GROUND_Y, 0.13, (v) => { atk.pos.y = v }, k.easings.easeInQuad),
|
|
])
|
|
def.angle = 0; sfxExplosion(); k.shake(isCritical ? 28 : 16); spawnShockwave(def.pos.x, GROUND_Y, '#ff2d2d')
|
|
} else if (slamType === 'spin') {
|
|
// Spin throw
|
|
await k.tween(0, 1, 0.35, (t) => {
|
|
def.pos.y = origDY - Math.sin(t * Math.PI) * height * 0.5
|
|
def.angle = t * 720
|
|
def.pos.x += dir * 2
|
|
})
|
|
def.angle = 0; sfxBonk(); k.shake(isCritical ? 20 : 10)
|
|
} else {
|
|
// Toss across screen
|
|
const tossX = origDX + dir * (isCritical ? 200 : 120)
|
|
await Promise.all([
|
|
k.tween(def.pos.x, tossX, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(origDY, origDY - height, 0.12, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(origDY - height, origDY, 0.13, (v) => { def.pos.y = v }, k.easings.easeInQuad)),
|
|
])
|
|
sfxBonk(); k.shake(isCritical ? 18 : 9); spawnShockwave(def.pos.x, GROUND_Y, theme.accent)
|
|
}
|
|
if (isCritical) { screenFlash(theme.accent); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 18 : 8, theme.accent)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
await k.wait(0.3); atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(atk.pos.y, GROUND_Y, 0.15, (v) => { atk.pos.y = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GENERATED CHOREOGRAPHIES — 258 new moves from factories
|
|
// ============================================================
|
|
|
|
// --- FOOD THROWS (30) — key items use detailed pixel props ---
|
|
const pizzaSlam = makeThrowProp('pizza', 2, 70, '#ffcc00')
|
|
const bananaFling = makeThrowProp('banana', 3, 55, '#ffff00')
|
|
const pieSmash = makeThrowProp('pie', 1, 80, '#ffffff')
|
|
const hotdogWhip = makeThrowProp('hotdog', 2, 45, '#ff6644')
|
|
const watermelonBomb = makeThrowProp('watermelon', 1, 90, '#ff4466')
|
|
const sushiBarrage = makeThrow('#ffffff', 10, 5, 40, false, '#ff6666')
|
|
const burgerToss = makeThrowProp('burger', 2, 65, '#ffaa00')
|
|
const iceCreamFling = makeThrow('#ffccdd', 12, 3, 50, true, '#ff88bb')
|
|
const tacoStorm = makeThrow('#ffcc44', 11, 4, 55, false, '#ff8800')
|
|
const donutBarrage = makeThrowProp('donut', 3, 60, '#ffaadd')
|
|
const popcornBlast = makeThrow('#ffffcc', 6, 12, 35, true, '#ffee88')
|
|
const cookieFling = makeThrow('#cc9944', 12, 3, 50, true, '#aa7722')
|
|
const eggBombard = makeThrow('#ffffdd', 10, 4, 65, true, '#ffee44')
|
|
const baguetteStrike = makeSwing(6, 55, '#dda855', '#cc9944', 10, 8)
|
|
const coffeeSplash = makeThrow('#443322', 10, 3, 45, true, '#886644')
|
|
const ramenWhip = makeThrow('#ffee88', 8, 6, 35, false, '#ffffff')
|
|
const nachoVolley = makeThrow('#ffcc22', 8, 8, 40, false, '#ff8800')
|
|
const pretzelFling = makeThrow('#aa7733', 14, 2, 60, false, '#cc9955')
|
|
const meatballStorm = makeThrow('#883322', 12, 5, 55, true, '#aa4433')
|
|
const popsicleJab = makeSwing(5, 45, '#ff66aa', '#ffffff', 12, 10)
|
|
const candyBarrage = makeThrow('#ff44ff', 8, 7, 40, true, '#ffaaff')
|
|
const waffleSlam = makeThrow('#dda844', 16, 2, 70, false, '#ffcc66')
|
|
const pancakeFrisbee = makeThrow('#eebb55', 14, 3, 30, true, '#ffdd88')
|
|
const drumstickSmack = makeSwing(7, 40, '#cc8844', '#aa6633', 14, 12)
|
|
const cornCobCannon = makeThrow('#ffee44', 10, 4, 50, false, '#44aa22')
|
|
const pickleJavelin = makeThrow('#669933', 8, 3, 45, false, '#88bb44')
|
|
const cabbagePunt = makeThrow('#44aa33', 18, 1, 85, true, '#66cc44')
|
|
const breadLoafBash = makeSwing(10, 35, '#dda855')
|
|
const cheeseWheel = makeThrow('#ffcc00', 20, 1, 80, true, '#ffee44')
|
|
const fishSlap = makeSwing(8, 40, '#8899aa', '#667788', 15, 8)
|
|
|
|
// --- WEAPON SWINGS (20) ---
|
|
const baseballBat = makeSwing(6, 55, '#aa7744', '#996633', 16, 10)
|
|
const fryingPan = makeSwing(5, 40, '#333333', '#444444', 22, 4)
|
|
const rollingPin = makeSwing(8, 45, '#dda855')
|
|
const umbrellaWhack = makeSwing(5, 55, '#222222', '#ff4444', 30, 3)
|
|
const mopSwipe = makeSwing(4, 60, '#aa9977', '#dddddd', 14, 12)
|
|
const broomStrike = makeSwing(4, 58, '#886633', '#558833', 12, 10)
|
|
const wrenchSmash = makeSwing(6, 38, '#888888', '#aaaaaa', 18, 8)
|
|
const crowbarSwing = makeSwing(5, 50, '#555555', '#444444', 8, 16)
|
|
const plungerSlam = makeSwing(5, 48, '#aa8844', '#ff4444', 12, 12)
|
|
const tennisRacket = makeSwing(3, 42, '#666666', '#44aa44', 20, 14)
|
|
const hockeyStick = makeSwing(4, 55, '#663300', '#222222', 12, 8)
|
|
const poolCue = makeSwing(3, 65, '#aa8855')
|
|
const guitarSmash = makeSwing(8, 50, '#884422', '#ffcc00', 18, 8)
|
|
const shovelBash = makeSwing(5, 52, '#666666', '#888888', 16, 12)
|
|
const pickaxeStrike = makeSwing(4, 50, '#886644', '#888888', 14, 8)
|
|
const oarSwing = makeSwing(5, 58, '#bb9966')
|
|
const flagpoleSmash = makeSwing(3, 65, '#888888', '#ff0000', 18, 12)
|
|
const caneWhack = makeSwing(4, 55, '#222222', '#ddaa00', 6, 6)
|
|
const rulerSlap = makeSwing(3, 50, '#ddcc88')
|
|
const newspaperRoll = makeSwing(6, 42, '#ccccbb')
|
|
|
|
// --- PROJECTILE STREAMS (20) ---
|
|
const tennisBallVolley = makeThrow('#ccff00', 8, 5, 40, true, '#aadd00')
|
|
const baseballPitch = makeThrow('#ffffff', 8, 4, 50, true, '#dddddd')
|
|
const bowlingBallRoll = makeThrow('#222222', 18, 1, 20, true, '#444444')
|
|
const rubberDuckFlood = makeThrow('#ffdd00', 10, 6, 45, true, '#ffee44')
|
|
const shoeBarrage = makeThrow('#553322', 12, 4, 55, false, '#775544')
|
|
const bookStorm = makeThrow('#884422', 14, 4, 60, false, '#aa6644')
|
|
const phoneFling = makeThrow('#333333', 10, 3, 50, false, '#4488ff')
|
|
const keyboardSmash = makeThrow('#dddddd', 16, 2, 65, false, '#aaaaaa')
|
|
const diceBarrage = makeThrow('#ffffff', 8, 6, 45, false, '#ff0000')
|
|
const marbleShower = makeThrow('#44aaff', 5, 10, 30, true, '#88ccff')
|
|
const snowballFight = makeThrow('#ffffff', 12, 4, 55, true, '#ddeeff')
|
|
const paintCanBlast = makeThrow('#ff4488', 14, 3, 60, true, '#ff88aa')
|
|
const tomatoBarrage = makeThrow('#ff2222', 10, 5, 50, true, '#ff4444')
|
|
const waterBalloonRain = makeThrow('#4488ff', 14, 3, 70, true, '#88bbff')
|
|
const pillowFight = makeThrow('#ffffff', 16, 3, 45, false, '#eeeeff')
|
|
const vinylRecordFling = makeThrow('#111111', 14, 3, 35, true, '#222222')
|
|
const frisbeeToss = makeThrow('#ff6600', 14, 2, 30, true, '#ffaa44')
|
|
const beachBallBonk = makeThrow('#ff4488', 20, 1, 80, true, '#ffaacc')
|
|
const soccerKick = makeThrow('#ffffff', 14, 2, 75, true, '#111111')
|
|
const basketballDunk = makeThrow('#ff8800', 16, 1, 90, true, '#ff6600')
|
|
|
|
// --- DROP ATTACKS (15) ---
|
|
const pianoDrop = makeDrop(55, 40, '#222222', '#ffcc00')
|
|
const safeDrop = makeDrop(35, 35, '#555555', '#888888')
|
|
const fridgeDrop = makeDrop(30, 45, '#dddddd', '#88ccff')
|
|
const couchDrop = makeDrop(55, 25, '#886644', '#aa8866')
|
|
const tvDrop = makeDrop(35, 30, '#333333', '#4488ff')
|
|
const toiletDrop = makeDrop(25, 30, '#ffffff', '#88bbff')
|
|
const bathtubDrop = makeDrop(50, 25, '#dddddd', '#88ccff')
|
|
const washerDrop = makeDrop(32, 35, '#cccccc', '#4488ff')
|
|
const vendingDrop = makeDrop(30, 45, '#ff4444', '#ffcc00')
|
|
const chandelierDrop = makeDrop(40, 30, '#ffcc00', '#ffffff')
|
|
const boulderDrop = makeDrop(35, 35, '#888888', '#666666', true)
|
|
const satelliteDrop = makeDrop(28, 35, '#888888', '#4488ff')
|
|
const ufoDrop = makeDrop(45, 15, '#88ff88', '#44ff44', false)
|
|
const giantShoeDrop = makeDrop(40, 25, '#553322', '#886644')
|
|
const giantPhoneDrop = makeDrop(22, 40, '#333333', '#4488ff')
|
|
|
|
// --- VEHICLES (10) ---
|
|
const shoppingCart = makeVehicle(45, 30, '#888888', 7, [{ w: 40, h: 3, color: '#aaaaaa', ox: 0, oy: -15 }])
|
|
const forkliftCharge = makeVehicle(50, 30, '#ffaa00', 8, [{ w: 8, h: 25, color: '#ffcc44', ox: 28, oy: -20 }])
|
|
const tankRoll = makeVehicle(65, 28, '#556633', 10, [{ w: 40, h: 6, color: '#445522', ox: 25, oy: -14 }])
|
|
const helicopterStrike = makeVehicle(50, 20, '#446688', 0, [{ w: 55, h: 3, color: '#557799', ox: 0, oy: -12 }])
|
|
const airplaneSwoop = makeVehicle(60, 15, '#dddddd', 0, [{ w: 50, h: 4, color: '#cccccc', ox: 0, oy: -3 }])
|
|
const rocketRide = makeVehicle(30, 50, '#ff4444', 0, [{ w: 20, h: 8, color: '#ffffff', ox: 0, oy: -28 }])
|
|
const unicycleRun = makeVehicle(12, 18, '#ff8800', 10)
|
|
const zamboniCrush = makeVehicle(60, 30, '#ffffff', 9, [{ w: 55, h: 5, color: '#88ccff', ox: 0, oy: 15 }])
|
|
const tractorPlow = makeVehicle(55, 32, '#44aa22', 10, [{ w: 30, h: 20, color: '#888888', ox: 30, oy: -8 }])
|
|
const golfCartDrive = makeVehicle(40, 25, '#ffffff', 6, [{ w: 15, h: 18, color: '#44aa44', ox: -8, oy: -16 }])
|
|
|
|
// --- SPIN ATTACKS (10) ---
|
|
const tornadoSpin = makeSpin('horizontal', 80, 3)
|
|
const backflipKick = makeSpin('vertical', 140, 1)
|
|
const corkscrewDive = makeSpin('corkscrew', 100, 2)
|
|
const helicopterArms = makeSpin('horizontal', 50, 4)
|
|
const breakdanceSweep = makeSpin('horizontal', 30, 3)
|
|
const cartwheelStrike = makeSpin('vertical', 90, 1)
|
|
const frontflipSlam = makeSpin('vertical', 160, 2)
|
|
const drillSpin = makeSpin('corkscrew', 60, 3)
|
|
const cycloneKick = makeSpin('horizontal', 100, 2)
|
|
const pirouetteStrike = makeSpin('horizontal', 40, 5)
|
|
|
|
// --- FLIP ATTACKS (10) ---
|
|
const doubleBackflip = makeSpin('vertical', 180, 2)
|
|
const wallBounce = makeSpin('vertical', 200, 1)
|
|
const springboardLaunch = makeSpin('vertical', 220, 1)
|
|
const barrelRoll = makeSpin('corkscrew', 80, 1)
|
|
const trampolineBounce = makeSpin('vertical', 250, 2)
|
|
const poleVault = makeSpin('vertical', 230, 1)
|
|
const skateTrick = makeSpin('corkscrew', 120, 1)
|
|
const surfSlam = makeSpin('horizontal', 60, 2)
|
|
const rolloutBall = makeSpin('horizontal', 20, 4)
|
|
const aerialTwist = makeSpin('corkscrew', 150, 2)
|
|
|
|
// --- BEAM ATTACKS (15) ---
|
|
const kamehameha = makeBeam('#44bbff', 10, 1)
|
|
const freezeRay = makeBeam('#88eeff', 8, 2)
|
|
const heatVision = makeBeam('#ff2200', 4, 3)
|
|
const plasmaBeam = makeBeam('#ff44ff', 8, 1)
|
|
const rainbowBeam = makeBeam('#ff8800', 12, 1)
|
|
const shadowBeam = makeBeam('#440066', 10, 1)
|
|
const sonicWave = makeBeam('#88ff88', 14, 2)
|
|
const gravityBeam = makeBeam('#6600aa', 6, 2)
|
|
const mindBlast = makeBeam('#ff88ff', 8, 1)
|
|
const pixelBeam = makeBeam('#44ff44', 6, 3)
|
|
const glitchBeam = makeBeam('#ff00ff', 5, 4)
|
|
const bassDropBeam = makeBeam('#8844ff', 16, 1)
|
|
const wifiBlast = makeBeam('#4488ff', 8, 2)
|
|
const dataStream = makeBeam('#00ff88', 4, 5)
|
|
const chainLightning = makeBeam('#ffff00', 5, 3)
|
|
|
|
// --- ANIMAL SUMMONS (15) ---
|
|
const sharkBite = makeSwarm('#4488aa', 1, 20, 600)
|
|
const bearSwipe = makeSwarm('#885533', 1, 22, 500)
|
|
const eagleDive = makeSwarm('#886644', 3, 12, 700, true)
|
|
const snakeLunge = makeSwarm('#44aa22', 2, 10, 550, true)
|
|
const bullCharge = makeSwarm('#883322', 1, 24, 450)
|
|
const gorillaSlam = makeSwarm('#555544', 1, 25, 400)
|
|
const scorpionSting = makeSwarm('#aa8822', 2, 8, 600, true)
|
|
const crabPinch = makeSwarm('#ff4422', 4, 10, 350)
|
|
const batSwarm = makeSwarm('#333333', 12, 5, 500, true)
|
|
const wolfPack = makeSwarm('#777766', 5, 12, 450)
|
|
const spiderWeb = makeSwarm('#dddddd', 8, 4, 300, true)
|
|
const beeSwarm = makeSwarm('#ffcc00', 10, 4, 550, true)
|
|
const catScratch = makeSwarm('#ff8844', 6, 6, 600, true)
|
|
const dogPile = makeSwarm('#aa8855', 4, 14, 400)
|
|
const dolphinFlip = makeSwarm('#6688cc', 3, 14, 500, true)
|
|
|
|
// --- EXPLOSION ATTACKS (15) ---
|
|
const dynamiteBlast = makeExplosion(8, 20, '#ff4444', 0.35, '#ff6600')
|
|
const c4Detonation = makeExplosion(14, 8, '#556633', 0.25, '#ff4400')
|
|
const fireworksBurst = makeExplosion(10, 10, '#ff0088', 0.2, '#ff44ff', true)
|
|
const nukeStrike = makeExplosion(12, 12, '#ffcc00', 0.4, '#ff2200', true)
|
|
const volcanoErupt = makeExplosion(20, 15, '#ff4400', 0.3, '#ff6600')
|
|
const grenadeBlast = makeExplosion(10, 12, '#556633', 0.3, '#ff6600', true)
|
|
const partyPopper = makeExplosion(8, 16, '#ff44ff', 0.15, '#ffcc00')
|
|
const pinataSmash = makeExplosion(20, 22, '#ff88cc', 0.2, '#ffaa44')
|
|
const balloonPop = makeExplosion(18, 18, '#ff4488', 0.1, '#ff88aa', true)
|
|
const glitterBomb = makeExplosion(12, 12, '#ffcc88', 0.15, '#ff88ff', true)
|
|
const smokeBomb = makeExplosion(14, 14, '#888888', 0.1, '#666666', true)
|
|
const flashBang = makeExplosion(8, 8, '#ffffff', 0.1, '#ffffff', true)
|
|
const cherryBomb = makeExplosion(8, 8, '#ff2222', 0.2, '#ff4444', true)
|
|
const confettiCannon = makeExplosion(16, 10, '#ffcc00', 0.1, '#ff88ff')
|
|
const stinkBomb = makeExplosion(12, 12, '#88aa33', 0.15, '#aacc44', true)
|
|
|
|
// --- SILLY/MEME (20) ---
|
|
const rubberChicken = makeSwing(8, 40, '#ffcc00', '#ff8844', 12, 12)
|
|
const airHornBlast = makeBeam('#ffcc00', 18, 1)
|
|
const vuvuzelaBlast = makeBeam('#ff4444', 16, 2)
|
|
const selfieStrike: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
// Pose, flash, then punch
|
|
atk.play('idle'); await k.wait(0.2)
|
|
screenFlash('#ffffff', 0.08); sfxSpecial(); await k.wait(0.15)
|
|
const cx = origDX - dir * 40
|
|
await k.tween(atk.pos.x, cx, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('attack'); sfxPunch(); await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 14 : 6)
|
|
if (isCritical) { screenFlash('#ffffff'); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 8, '#ffcc00')
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
const dabAttack = makeSpin('horizontal', 30, 1)
|
|
const flossAttack = makeSpin('horizontal', 20, 3)
|
|
const yeetThrow = makeGrab('toss', 180)
|
|
const tPoseAssert = makeBeam('#ffffff', 20, 1)
|
|
const emojiBarrage = makeThrow('#ffcc00', 12, 6, 50, true, '#ff8800')
|
|
const memeBeam = makeBeam('#ff00ff', 10, 2)
|
|
const ratioAttack = makeThrow('#4488ff', 10, 5, 40, true, '#2266dd')
|
|
const capThrow = makeThrow('#ff0000', 14, 1, 30, true, '#0000ff')
|
|
const fingerGuns: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial(); await k.wait(0.15)
|
|
const shots = isCritical ? 6 : 3
|
|
for (let i = 0; i < shots; i++) {
|
|
sfxGunshot()
|
|
await spawnBullet(atk.pos.x + dir * 25, atk.pos.y - 35, def.pos.x, def.pos.y - 25 + (Math.random() - 0.5) * 30)
|
|
sfxBulletHit(); def.play('hit'); k.shake(3)
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 3, '#ffcc00'); await k.wait(0.06)
|
|
}
|
|
if (isCritical) { screenFlash('#ffcc00'); sfxCritical() }
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 12 : 6)
|
|
const push = dir * (isCritical ? 80 : 35)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
const micDrop = makeDrop(12, 16, '#333333', '#ffcc00')
|
|
const dramaticEntrance = makeSpin('corkscrew', 200, 1)
|
|
const clapback = makeSwing(10, 5, '#ffaa88')
|
|
const touchGrass = makeSwarm('#44aa22', 15, 4, 300, true)
|
|
const noScope = makeBeam('#ff4400', 3, 1)
|
|
|
|
// --- GRAPPLE/WRESTLING (15) ---
|
|
const piledriver = makeGrab('down', 180)
|
|
const powerbomb = makeGrab('up', 200)
|
|
const ddt = makeGrab('down', 120)
|
|
const germanSuplex = makeGrab('up', 160)
|
|
const chokeslam = makeGrab('up', 220)
|
|
const tombstone = makeGrab('down', 200)
|
|
const stunner = makeGrab('down', 80)
|
|
const rko = makeGrab('down', 140)
|
|
const spear = makeGrab('toss', 80)
|
|
const clothesline: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
const cx = origDX - dir * 20
|
|
atk.play('attack'); sfxSpecial()
|
|
await k.tween(atk.pos.x, cx, 0.12, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
sfxPunch(); def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 9); spawnSparks(def.pos.x, def.pos.y - 30, 10, '#ff4444')
|
|
if (isCritical) { screenFlash('#ff4444'); sfxCritical() }
|
|
// Defender spins backwards
|
|
const origDY = def.pos.y
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + dir * (isCritical ? 120 : 60), 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(0, 360, 0.25, (v) => { def.angle = v }).then(() => { def.angle = 0 }),
|
|
k.tween(origDY, origDY - 50, 0.12, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(origDY - 50, origDY, 0.13, (v) => { def.pos.y = v }, k.easings.easeInQuad)),
|
|
])
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff4444')
|
|
await k.wait(0.2); atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
const atomicDrop = makeGrab('up', 140)
|
|
const hurricanrana = makeGrab('spin', 120)
|
|
const moonsault = makeSpin('vertical', 200, 2)
|
|
const elbowDrop = makeDrop(10, 8, '#ffaa88', '#ff4444')
|
|
const frogSplash = makeSpin('vertical', 180, 1)
|
|
|
|
// --- ENERGY/MAGIC (20) ---
|
|
const fireball = makeThrow('#ff4400', 14, 2, 30, true, '#ff8800')
|
|
const iceLance = makeThrow('#88eeff', 10, 3, 35, false, '#44ccff')
|
|
const thunderStrike = makeBeam('#ffff00', 6, 2)
|
|
const darkVoid = makeBeam('#440066', 12, 1)
|
|
const solarFlare: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial(); await k.wait(0.1)
|
|
// Blinding flash
|
|
screenFlash('#ffffff', 0.25)
|
|
await k.wait(0.2)
|
|
// Then punch while blinded
|
|
const cx = origDX - dir * 40
|
|
await k.tween(atk.pos.x, cx, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('attack'); sfxPunch(); await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 18 : 8)
|
|
if (isCritical) { screenFlash('#ffff00'); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 12, '#ffff00')
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3); atk.play('idle')
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
const spiritBomb = makeDrop(30, 30, '#44bbff', '#88ddff', true)
|
|
const windSlash = makeBeam('#aaffaa', 3, 4)
|
|
const earthquakeStrike: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial(); await k.wait(0.1)
|
|
atk.play('attack')
|
|
// Ground pound
|
|
const origAY = atk.pos.y
|
|
await k.tween(origAY, origAY + 15, 0.08, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(isCritical ? 30 : 18)
|
|
spawnShockwave(atk.pos.x, GROUND_Y, '#aa8844')
|
|
// Screen shakes violently, ground cracks spread toward opponent
|
|
for (let i = 0; i < (isCritical ? 4 : 2); i++) {
|
|
const cx2 = atk.pos.x + dir * (80 + i * 60)
|
|
spawnSparks(cx2, GROUND_Y - 5, 5, '#aa8844')
|
|
await k.wait(0.05)
|
|
}
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(10)
|
|
if (isCritical) { screenFlash('#aa8844'); sfxCritical() }
|
|
spawnSparks(def.pos.x, GROUND_Y - 5, 10, '#886644')
|
|
const origDY = def.pos.y
|
|
await k.tween(origDY, origDY - 80, 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(origDY - 80, origDY, 0.2, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#886644')
|
|
atk.pos.y = GROUND_Y
|
|
await k.wait(0.2); atk.play('idle')
|
|
}
|
|
const portalPunch: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => {
|
|
atk.play('special'); sfxSpecial()
|
|
// Open portal near attacker
|
|
const p1 = k.add([k.circle(20), k.pos(atk.pos.x + dir * 40, atk.pos.y - 30), k.color(safeColor(k,'#8844ff')), k.opacity(0.7), k.z(15)])
|
|
// Open portal near defender
|
|
const p2 = k.add([k.circle(20), k.pos(def.pos.x - dir * 30, def.pos.y - 30), k.color(safeColor(k,'#ff44ff')), k.opacity(0.7), k.z(15)])
|
|
await k.wait(0.2)
|
|
atk.play('attack'); sfxPunch()
|
|
// Fist appears from portal 2
|
|
await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8)
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 10, '#ff44ff')
|
|
if (isCritical) { screenFlash('#8844ff'); sfxCritical() }
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.15)
|
|
k.tween(0.7, 0, 0.2, (v) => { p1.opacity = v; p2.opacity = v }).then(() => { p1.destroy(); p2.destroy() })
|
|
await k.wait(0.2); atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
const gravityWell = makeSwarm('#6600aa', 1, 25, 300)
|
|
const plasmaOrb = makeThrow('#ff44ff', 16, 1, 40, true, '#ff88ff')
|
|
const voidRift = makeBeam('#220044', 14, 1)
|
|
const crystalShards = makeThrow('#88ffff', 8, 6, 45, false, '#44ddff')
|
|
const meteorStrike = makeDrop(25, 25, '#ff4400', '#ff8800', true)
|
|
const arcaneBarrage = makeThrow('#8844ff', 10, 5, 50, true, '#aa66ff')
|
|
const holySmite = makeBeam('#ffee88', 10, 1)
|
|
const hexCurse = makeSwarm('#44ff44', 6, 6, 400, true)
|
|
const bloodMoon = makeBeam('#ff2222', 12, 1)
|
|
const shadowClone = makeSwarm('#333333', 3, 16, 500)
|
|
|
|
// --- TECH/MODERN (15) ---
|
|
const droneStrike = makeSwarm('#888888', 3, 8, 600)
|
|
const satelliteLaser = makeBeam('#ff0000', 4, 1)
|
|
const hackerAttack = makeThrow('#00ff44', 8, 8, 30, false, '#44ff88')
|
|
const bugSwarm = makeSwarm('#ff4444', 10, 4, 500, true)
|
|
const stackOverflow = makeDrop(35, 35, '#ff8800', '#f48024')
|
|
const segfault = makeExplosion(20, 20, '#ff0000', 0.1, '#880000')
|
|
const blueScreen = makeBeam('#0000ff', 20, 1)
|
|
const fourOhFour = makeExplosion(15, 15, '#888888', 0.2, '#ff4444')
|
|
const ctrlAltDelete = makeSwing(8, 12, '#333333', '#ffffff', 20, 12)
|
|
const bitcoinCrash = makeDrop(20, 20, '#ff8800', '#ffcc00', true)
|
|
const nftRugPull = makeGrab('toss', 200)
|
|
const aiUprising = makeSwarm('#ff0044', 8, 8, 500, true)
|
|
const captchaTrap = makeExplosion(25, 20, '#4488ff', 0.3, '#2266cc')
|
|
const popupSpam = makeThrow('#ffffff', 14, 8, 30, false, '#ff4444')
|
|
const malwareInject = makeBeam('#00ff00', 5, 3)
|
|
|
|
// --- MUSIC (8) ---
|
|
const bassDrop = makeExplosion(10, 10, '#8844ff', 0.2, '#6622cc', true)
|
|
const guitarSolo = makeBeam('#ff8800', 8, 3)
|
|
const drumSolo = makeThrow('#aa8844', 10, 6, 45, true, '#886633')
|
|
const dubstepCannon = makeBeam('#ff00ff', 14, 2)
|
|
const airGuitar = makeBeam('#ffcc00', 6, 3)
|
|
const karaokeAttack = makeBeam('#ff44aa', 10, 2)
|
|
const beatboxBlast = makeSwarm('#4488ff', 8, 6, 400, true)
|
|
const vinylScratch = makeSwing(14, 2, '#111111', '#ff4444', 14, 14)
|
|
|
|
// --- SPORTS (10) ---
|
|
const homeRunSwing = makeSwing(5, 55, '#886633', '#ffffff', 10, 10)
|
|
const slapShot = makeSwing(4, 55, '#333333', '#444444', 8, 8)
|
|
const servingAce = makeThrow('#ccff00', 10, 1, 80, true, '#aadd00')
|
|
const fieldGoalKick = makeSpin('vertical', 100, 1)
|
|
const bodyCheck = makeGrab('toss', 60)
|
|
const dropKick = makeSpin('vertical', 120, 1)
|
|
const elbowStrike = makeGrab('down', 60)
|
|
const kneeStrike = makeGrab('up', 50)
|
|
const headbutt = makeGrab('down', 30)
|
|
const shoulderCharge = makeGrab('toss', 40)
|
|
|
|
// --- NATURE/ELEMENTAL (10) ---
|
|
const treeSmash = makeDrop(20, 60, '#44aa22', '#228811')
|
|
const icebergDrop = makeDrop(40, 35, '#88eeff', '#44ccff')
|
|
const lavaSplash = makeExplosion(15, 15, '#ff4400', 0.2, '#ff8800', true)
|
|
const tornadoFling = makeGrab('spin', 160)
|
|
const tsunamiWave = makeBeam('#2266cc', 20, 1)
|
|
const avalanche = makeSwarm('#ffffff', 12, 10, 400)
|
|
const vineWhip = makeSwing(3, 60, '#44aa22', '#33aa11', 6, 12)
|
|
const sandstorm = makeSwarm('#ccaa66', 15, 4, 350, true)
|
|
const thornBarrage = makeThrow('#44aa22', 6, 8, 35, false, '#228811')
|
|
const pollenCloud = makeSwarm('#ffee44', 12, 3, 250, true)
|
|
|
|
// --- SPACE/SCI-FI (10) ---
|
|
const blackHole = makeSwarm('#220044', 1, 30, 200)
|
|
const photonTorpedo = makeThrow('#44bbff', 12, 3, 40, true, '#88ddff')
|
|
const tractor_beam = makeBeam('#44ff88', 10, 1)
|
|
const warpDrive = makeSpin('corkscrew', 180, 1)
|
|
const alienAbduction = makeGrab('up', 250)
|
|
const laserSword = makeSwing(4, 55, '#44bbff', '#88ddff', 4, 50)
|
|
const ionCannon = makeBeam('#88ff44', 8, 2)
|
|
const plasmaSword = makeSwing(4, 55, '#ff44ff', '#ff88ff', 4, 50)
|
|
const cosmicRay = makeBeam('#ffffff', 3, 5)
|
|
const asteroidBelt = makeThrow('#888888', 14, 5, 60, true, '#666666')
|
|
|
|
// --- MEDIEVAL/FANTASY (10) ---
|
|
const dragonBreath = makeBeam('#ff4400', 12, 2)
|
|
const maceSwing = makeSwing(5, 48, '#888888', '#666666', 16, 16)
|
|
const crossbowBolt = makeThrow('#886633', 6, 3, 25, false, '#aa8855')
|
|
const shieldBash = makeGrab('toss', 50)
|
|
const flailSwing = makeSwing(3, 55, '#888888', '#666666', 14, 14)
|
|
const battleAxe = makeSwing(6, 50, '#886633', '#888888', 20, 12)
|
|
const holyWater = makeThrow('#88ccff', 10, 3, 55, true, '#aaeeff')
|
|
const potionThrow = makeThrow('#44ff44', 10, 2, 60, true, '#88ff88')
|
|
const scrollBlast = makeBeam('#ffcc88', 8, 1)
|
|
const enchantedArrow = makeThrow('#ff88ff', 6, 3, 30, false, '#ffaaff')
|
|
|
|
// ============================================================
|
|
// TIER-GATED ULTIMATE MOVES — unlock with higher tier/wins
|
|
// Tier 2+: 10 moves, Tier 3+: 10 more, Tier 4+: 10 more, Tier 5+: 10 more
|
|
// ============================================================
|
|
|
|
// --- TIER 2+ ULTIMATES (Silver) — flashy multi-hits ---
|
|
async function ultimateRapidBarrage(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Rush in and land 8 alternating punches + kicks with screen shake
|
|
const contactX = origDX - dir * 45
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
const hitColors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#39ff14', '#ff6600', '#b83dff', '#ffffff', '#ff2d2d']
|
|
for (let i = 0; i < 8; i++) {
|
|
atk.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxPunch()
|
|
def.play('hit')
|
|
k.shake(3 + i)
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 20, def.pos.y - 15 - Math.random() * 30, 4, hitColors[i])
|
|
def.pos.x += dir * 3
|
|
await k.wait(0.04)
|
|
}
|
|
// Final uppercut launch
|
|
atk.play('special'); sfxCritical()
|
|
def.play('knockback'); k.shake(15); screenFlash('#ffe14d', 0.1)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ffe14d')
|
|
await k.tween(def.pos.y, def.pos.y - 60, 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(def.pos.y, GROUND_Y, 0.15, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk()
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
])
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function ultimateOrbitalStrike(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Call down 5 orbital beams on the defender's position
|
|
atk.play('special'); sfxSpecial()
|
|
for (let i = 0; i < 5; i++) {
|
|
const bx = def.pos.x + (Math.random() - 0.5) * 60
|
|
const beam = k.add([k.rect(4, H), k.pos(bx, 0), k.color(safeColor(k,'#ff2d7b')), k.opacity(0.8), k.z(20)])
|
|
sfxZap(); k.shake(6)
|
|
spawnSparks(bx, def.pos.y - 20, 8, '#ff2d7b')
|
|
await k.wait(0.08)
|
|
beam.destroy()
|
|
}
|
|
def.play('knockback'); k.shake(12); screenFlash('#ff2d7b', 0.15)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff2d7b')
|
|
await k.wait(0.3); atk.play('idle')
|
|
}
|
|
|
|
async function ultimateCloneSurround(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Spawn 4 clones around the defender, all attack simultaneously
|
|
const clones: any[] = []
|
|
const positions = [
|
|
{ x: def.pos.x - 60, y: GROUND_Y }, { x: def.pos.x + 60, y: GROUND_Y },
|
|
{ x: def.pos.x - 30, y: GROUND_Y - 50 }, { x: def.pos.x + 30, y: GROUND_Y - 50 },
|
|
]
|
|
for (const p of positions) {
|
|
const cl = k.add([k.rect(20, 30), k.pos(p.x, p.y), k.anchor('center'), k.color(safeColor(k,theme.accent)), k.opacity(0.5), k.z(12)])
|
|
clones.push(cl)
|
|
}
|
|
sfxZap(); await k.wait(0.2)
|
|
// All converge
|
|
for (const cl of clones) {
|
|
k.tween(cl.pos.x, def.pos.x, 0.1, (v) => { cl.pos.x = v }, k.easings.easeInQuad)
|
|
k.tween(cl.pos.y, def.pos.y - 20, 0.1, (v) => { cl.pos.y = v }, k.easings.easeInQuad)
|
|
}
|
|
await k.wait(0.12)
|
|
sfxExplosion(); k.shake(15); screenFlash('#ffffff', 0.15)
|
|
def.play('knockback')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 20, theme.accent)
|
|
clones.forEach(c => c.destroy())
|
|
await k.wait(0.3)
|
|
}
|
|
|
|
async function ultimateGatlingFury(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Pull out massive gatling gun, fire 12 shots with screen shake
|
|
atk.play('special')
|
|
const gunLen = 60
|
|
const gx = atk.pos.x + dir * 25
|
|
const gy = atk.pos.y - 25
|
|
const barrel = k.add([k.rect(gunLen, 16), k.pos(gx, gy), k.color(safeColor(k,'#555555')), k.opacity(1), k.z(16)])
|
|
sfxBoing()
|
|
await k.wait(0.15)
|
|
for (let i = 0; i < 12; i++) {
|
|
sfxGunshot()
|
|
k.shake(3)
|
|
const flash = k.add([k.circle(8), k.pos(gx + dir * gunLen, gy + (Math.random() - 0.5) * 10), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)])
|
|
setTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
|
|
if (i % 3 === 0) {
|
|
def.play('hit')
|
|
spawnSparks(def.pos.x, def.pos.y - 20 - Math.random() * 20, 4, '#ff6600')
|
|
}
|
|
await k.wait(0.04)
|
|
}
|
|
barrel.destroy()
|
|
def.play('knockback'); k.shake(10)
|
|
sfxExplosion()
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff6600')
|
|
await k.wait(0.3); atk.play('idle')
|
|
}
|
|
|
|
async function ultimateEarthquake(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Jump high, slam ground, cracks + shockwaves across screen
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.y, GROUND_Y - 150, 0.2, (v) => { atk.pos.y = v }, k.easings.easeOutQuad)
|
|
atk.play('special')
|
|
await k.wait(0.1)
|
|
await k.tween(atk.pos.y, GROUND_Y, 0.1, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(25); screenFlash('#ff8800', 0.2)
|
|
// 3 shockwaves radiating outward
|
|
for (let i = 0; i < 3; i++) {
|
|
setTimeout(() => { spawnShockwave(atk.pos.x + (i - 1) * 100, GROUND_Y, '#ff8800'); k.shake(8) }, i * 100)
|
|
}
|
|
// Ground cracks
|
|
for (let i = 0; i < 6; i++) {
|
|
const cx = Math.random() * W
|
|
k.add([k.rect(2, 15 + Math.random() * 10), k.pos(cx, GROUND_Y - 5), k.color(safeColor(k,'#aa6600')), k.opacity(0.7), k.z(1), { lifetime: 0.8 }])
|
|
}
|
|
def.play('knockback')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 12, '#ff8800')
|
|
await k.wait(0.4)
|
|
atk.play('idle')
|
|
}
|
|
|
|
const ultimateBouncingFury = makeSpin('corkscrew', 160, 4)
|
|
const ultimateTripleBeam = makeBeam('#ff2d7b', 10, 3)
|
|
const ultimateMegaThrow = makeThrow('#ff4400', 22, 5, 80, true, '#ffcc00')
|
|
const ultimateTornadoGrab = makeGrab('spin', 200)
|
|
const ultimateMeteorShower = makeSwarm('#ff6600', 8, 10, 500, true)
|
|
|
|
// --- TIER 3+ ULTIMATES (Gold) — multi-phase, full screen ---
|
|
async function ultimateScreenNuke(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Charge up glowing orb, launch it, entire screen whites out
|
|
atk.play('special'); sfxSpecial()
|
|
const orb = k.add([k.circle(5), k.pos(atk.pos.x + dir * 20, atk.pos.y - 30), k.color(safeColor(k,'#ffffff')), k.opacity(0.8), k.z(18)])
|
|
// Charge: grow orb
|
|
for (let i = 0; i < 8; i++) {
|
|
await k.wait(0.05)
|
|
orb.radius = 5 + i * 3
|
|
spawnSparks(orb.pos.x + (Math.random() - 0.5) * 20, orb.pos.y + (Math.random() - 0.5) * 20, 2, '#ffe14d')
|
|
}
|
|
sfxZoomWhoosh()
|
|
// Launch
|
|
await k.tween(orb.pos.x, def.pos.x, 0.15, (v) => { orb.pos.x = v }, k.easings.easeInQuad)
|
|
orb.destroy()
|
|
// Full screen explosion
|
|
sfxExplosion(); sfxCritical()
|
|
screenFlash('#ffffff', 0.3); k.shake(30)
|
|
for (let i = 0; i < 4; i++) {
|
|
spawnShockwave(def.pos.x + (Math.random() - 0.5) * 80, GROUND_Y, ['#ff2d7b', '#ffe14d', '#00f0ff', '#b83dff'][i])
|
|
}
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffffff')
|
|
def.play('knockback')
|
|
await k.wait(0.4); atk.play('idle')
|
|
}
|
|
|
|
async function ultimateBlackHoleVortex(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Summon black hole that pulls defender in, crushes, and ejects
|
|
atk.play('special'); sfxZap()
|
|
const bh = k.add([k.circle(8), k.pos(W / 2, GROUND_Y - 50), k.anchor('center'), k.color(safeColor(k,'#220044')), k.opacity(0.9), k.z(20)])
|
|
const ring = k.add([k.circle(30), k.pos(W / 2, GROUND_Y - 50), k.anchor('center'), k.color(safeColor(k,'#b83dff')), k.opacity(0.3), k.z(19)])
|
|
// Grow
|
|
await k.tween(8, 35, 0.3, (v) => { bh.radius = v; ring.radius = v + 20 }, k.easings.easeOutQuad)
|
|
// Pull defender in
|
|
sfxZoomWhoosh()
|
|
await k.tween(def.pos.x, W / 2, 0.2, (v) => { def.pos.x = v }, k.easings.easeInQuad)
|
|
await k.tween(def.pos.y, GROUND_Y - 50, 0.15, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
def.play('hit'); k.shake(10)
|
|
// Crush
|
|
for (let i = 0; i < 5; i++) {
|
|
k.shake(5); sfxPunch()
|
|
spawnSparks(W / 2 + (Math.random() - 0.5) * 30, GROUND_Y - 50 + (Math.random() - 0.5) * 20, 3, '#b83dff')
|
|
await k.wait(0.06)
|
|
}
|
|
// Eject
|
|
sfxExplosion(); k.shake(20); screenFlash('#b83dff', 0.15)
|
|
def.play('knockback')
|
|
await Promise.all([
|
|
k.tween(def.pos.x, origDX + dir * 40, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.y, GROUND_Y, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad),
|
|
])
|
|
bh.destroy(); ring.destroy()
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#b83dff')
|
|
await k.tween(def.pos.x, origDX, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.2); atk.play('idle')
|
|
}
|
|
|
|
async function ultimateDimensionalRift(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Open rift, attacker disappears, attacks from multiple portals
|
|
sfxZap(); atk.opacity = 0
|
|
const colors = ['#ff2d7b', '#00f0ff', '#39ff14', '#b83dff']
|
|
for (let i = 0; i < 4; i++) {
|
|
const px = def.pos.x + (i % 2 === 0 ? -80 : 80)
|
|
const py = GROUND_Y - (i < 2 ? 0 : 60)
|
|
// Portal flash
|
|
const p = k.add([k.circle(15), k.pos(px, py), k.color(safeColor(k,colors[i])), k.opacity(0.7), k.z(15)])
|
|
await k.wait(0.06)
|
|
// Attack from portal
|
|
atk.pos.x = px; atk.pos.y = py; atk.opacity = 1
|
|
atk.play('attack'); sfxPunch()
|
|
def.play('hit'); k.shake(5)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 5, colors[i])
|
|
await k.wait(0.06)
|
|
atk.opacity = 0; p.destroy()
|
|
}
|
|
// Final slam from above
|
|
atk.pos.x = def.pos.x; atk.pos.y = GROUND_Y - 120; atk.opacity = 1
|
|
atk.play('special')
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.y, GROUND_Y, 0.12, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(18); screenFlash('#ffffff', 0.15)
|
|
def.play('knockback')
|
|
spawnSparks(def.pos.x, GROUND_Y - 20, 20, '#ffffff')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffffff')
|
|
await k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function ultimateTimeSlow(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Everything goes slow-mo, attacker lands 6 devastating hits in "frozen" time
|
|
scanlineGlitch(0.1)
|
|
// Dim screen
|
|
const dim = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k,'#000022')), k.opacity(0.4), k.z(25)])
|
|
sfxZap()
|
|
const contactX = origDX - dir * 45
|
|
await k.tween(atk.pos.x, contactX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad)
|
|
for (let i = 0; i < 6; i++) {
|
|
atk.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special')
|
|
sfxPunch()
|
|
def.play('hit')
|
|
k.shake(4)
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 15, def.pos.y - 15 - Math.random() * 25, 5, '#00f0ff')
|
|
await k.wait(0.12) // Slow timing
|
|
}
|
|
dim.destroy()
|
|
// Time resumes — final hit with full impact
|
|
sfxCritical(); sfxExplosion(); k.shake(20); screenFlash('#00f0ff', 0.2)
|
|
def.play('knockback')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#00f0ff')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 20, '#00f0ff')
|
|
await k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function ultimateSummonArmy(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Summon an army of 8 mini clones that charge across the screen
|
|
atk.play('special'); sfxSpecial()
|
|
const army: any[] = []
|
|
for (let i = 0; i < 8; i++) {
|
|
const ax = atk.pos.x + (Math.random() - 0.5) * 30
|
|
const ay = GROUND_Y - Math.random() * 10
|
|
const m = k.add([k.rect(12, 18), k.pos(ax, ay), k.anchor('center'), k.color(safeColor(k,theme.accent)), k.opacity(0.6), k.z(11)])
|
|
army.push(m)
|
|
}
|
|
await k.wait(0.2)
|
|
sfxZoomWhoosh()
|
|
// All charge at defender
|
|
await Promise.all(army.map((m, i) =>
|
|
k.tween(m.pos.x, def.pos.x + (Math.random() - 0.5) * 20, 0.15 + i * 0.02, (v) => { m.pos.x = v }, k.easings.easeInQuad)
|
|
))
|
|
sfxExplosion(); k.shake(15)
|
|
def.play('knockback')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 20, theme.accent)
|
|
army.forEach(m => m.destroy())
|
|
await k.wait(0.3)
|
|
}
|
|
|
|
const ultimateHellfireRain = makeSwarm('#ff4400', 12, 12, 600, true)
|
|
const ultimateCrystalCage = makeSwarm('#88ccff', 10, 8, 350, false)
|
|
const ultimateGigaDrill = makeSpin('corkscrew', 200, 5)
|
|
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: Fighter, def: Fighter, 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: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Full-screen dash through, reverse, dash again, then combo finish
|
|
sfxZoomWhoosh()
|
|
// First pass
|
|
await k.tween(atk.pos.x, origDX + dir * 60, 0.1, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
atk.play('attack'); sfxPunch(); def.play('hit'); k.shake(8)
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 8, '#ff2d7b')
|
|
// Bounce off wall
|
|
await k.wait(0.05)
|
|
atk.scale.x = -atk.scale.x
|
|
await k.tween(atk.pos.x, origDX - dir * 60, 0.1, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
atk.play('kick'); sfxKick(); def.play('hit'); k.shake(8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 8, '#00f0ff')
|
|
// Back to normal orientation
|
|
atk.scale.x = -atk.scale.x
|
|
// Final mega hit
|
|
await k.tween(atk.pos.x, origDX - dir * 40, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
atk.play('special'); sfxCritical(); sfxExplosion()
|
|
def.play('knockback'); k.shake(22); screenFlash('#ffe14d', 0.2)
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 25, '#ffe14d')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d')
|
|
// Return
|
|
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')
|
|
}
|
|
|
|
async function ultimateMeteorCrash(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Attacker launches into sky, becomes a meteor, crashes into arena
|
|
sfxZoomWhoosh(); atk.play('special')
|
|
await k.tween(atk.pos.y, -100, 0.2, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
atk.opacity = 0
|
|
await k.wait(0.3)
|
|
// Meteor warning
|
|
const warning = k.add([k.circle(15), k.pos(def.pos.x, GROUND_Y - 5), k.color(safeColor(k,'#ff0000')), k.opacity(0.4), k.z(1)])
|
|
warning.onUpdate(() => { warning.opacity = 0.2 + Math.sin(k.time() * 20) * 0.2 })
|
|
sfxZap()
|
|
await k.wait(0.4)
|
|
warning.destroy()
|
|
// Impact
|
|
atk.pos.x = def.pos.x; atk.pos.y = -80; atk.opacity = 1
|
|
// Fire trail
|
|
const fireTrail: any[] = []
|
|
await k.tween(-80, GROUND_Y, 0.15, (v) => {
|
|
atk.pos.y = v
|
|
const f = k.add([k.circle(6), k.pos(atk.pos.x + (Math.random() - 0.5) * 15, v - 15), k.color(safeColor(k,'#ff6600')), k.opacity(0.7), k.z(9)])
|
|
fireTrail.push(f)
|
|
k.tween(f.opacity, 0, 0.3, (op) => { f.opacity = op }).then(() => { if (f.exists()) f.destroy() })
|
|
}, k.easings.easeInQuad)
|
|
sfxExplosion(); sfxCritical(); k.shake(30); screenFlash('#ff6600', 0.3)
|
|
def.play('knockback')
|
|
for (let i = 0; i < 5; i++) {
|
|
spawnShockwave(def.pos.x + (Math.random() - 0.5) * 60, GROUND_Y, ['#ff6600', '#ff2d2d', '#ffe14d', '#ff8800', '#ffffff'][i])
|
|
}
|
|
spawnSparks(def.pos.x, GROUND_Y - 20, 30, '#ff6600')
|
|
fireTrail.forEach(f => { if (f.exists()) f.destroy() })
|
|
await k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function ultimateScreenShatter(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Hit so hard the "screen" shatters with fragment overlay
|
|
const contactX = origDX - dir * 45
|
|
await k.tween(atk.pos.x, contactX, 0.1, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('special'); sfxCritical()
|
|
def.play('knockback'); k.shake(25)
|
|
// Create "shatter" fragments
|
|
const frags: any[] = []
|
|
for (let i = 0; i < 12; i++) {
|
|
const fx = W * Math.random()
|
|
const fy = H * Math.random() * 0.8
|
|
const fw = 30 + Math.random() * 60
|
|
const fh = 20 + Math.random() * 40
|
|
const frag = k.add([k.rect(fw, fh), k.pos(fx, fy), k.color(safeColor(k,'#111111')), k.opacity(0.7), k.z(30), k.rotate(Math.random() * 30 - 15)])
|
|
frags.push(frag)
|
|
}
|
|
sfxExplosion(); screenFlash('#ffffff', 0.3)
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 25, '#ffffff')
|
|
await k.wait(0.5)
|
|
// Fragments fall away
|
|
await Promise.all(frags.map(f =>
|
|
k.tween(f.pos.y, H + 50, 0.4, (v) => { f.pos.y = v; f.opacity *= 0.98 }, k.easings.easeInQuad).then(() => f.destroy())
|
|
))
|
|
await k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function ultimateGravityReverse(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Flip gravity — defender floats up, gets juggled, slammed back down
|
|
atk.play('special'); sfxZap()
|
|
// Defender floats up
|
|
await k.tween(def.pos.y, GROUND_Y - 140, 0.3, (v) => { def.pos.y = v }, k.easings.easeOutQuad)
|
|
def.play('hit')
|
|
// Juggle with projectiles
|
|
for (let i = 0; i < 4; i++) {
|
|
await spawnProjectile(atk.pos.x + dir * 20, atk.pos.y - 30, def.pos.x, def.pos.y, '#b83dff', 8)
|
|
sfxBulletHit(); k.shake(4)
|
|
def.pos.y += (i % 2 === 0 ? -15 : 15)
|
|
spawnSparks(def.pos.x, def.pos.y, 4, '#b83dff')
|
|
await k.wait(0.06)
|
|
}
|
|
// Slam down
|
|
sfxZoomWhoosh()
|
|
await k.tween(def.pos.y, GROUND_Y, 0.12, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(20); screenFlash('#b83dff', 0.15)
|
|
def.play('knockback')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#b83dff')
|
|
spawnSparks(def.pos.x, GROUND_Y - 10, 20, '#b83dff')
|
|
await k.wait(0.3); atk.play('idle')
|
|
}
|
|
|
|
async function ultimateInfiniteCombo(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// 15-hit combo with escalating screen effects
|
|
const contactX = origDX - dir * 40
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
const hitAnims = ['attack', 'kick', 'special', 'attack', 'kick', 'special', 'attack', 'kick', 'attack', 'kick', 'special', 'attack', 'kick', 'attack', 'special']
|
|
for (let i = 0; i < 15; i++) {
|
|
atk.play(hitAnims[i])
|
|
sfxPunch()
|
|
def.play('hit')
|
|
k.shake(2 + Math.floor(i / 3))
|
|
const hue = (i * 25) % 360
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 15, def.pos.y - 10 - Math.random() * 30, 3, `hsl(${hue}, 100%, 60%)`)
|
|
if (i % 5 === 4) screenFlash(`hsl(${hue}, 100%, 50%)`, 0.05)
|
|
def.pos.x += dir * 2
|
|
await k.wait(0.03)
|
|
}
|
|
// Final blow
|
|
atk.play('special'); sfxCritical(); sfxExplosion()
|
|
def.play('knockback'); k.shake(25); screenFlash('#ffffff', 0.2)
|
|
spawnSparks(def.pos.x, def.pos.y - 25, 25, '#ffe14d')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d')
|
|
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')
|
|
}
|
|
|
|
const ultimatePlasmaStorm = makeSwarm('#ff00ff', 15, 10, 700, true)
|
|
const ultimateFrozenCoffin = makeExplosion(35, 50, '#88ccff', 0.4, '#aaeeff', false)
|
|
const ultimateLavaGeyser = makeBeam('#ff4400', 14, 4)
|
|
const ultimateChainGrab = makeGrab('spin', 280)
|
|
const ultimateRocketBarrage = makeSwarm('#ff6600', 10, 14, 550, false)
|
|
|
|
// --- TIER 5+ ULTIMATES (Diamond/Legend) — absolutely insane full-screen ---
|
|
async function ultimateArmageddon(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Full screen goes dark, rain of fire, massive explosion, screen flash
|
|
const darkness = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0), k.z(25)])
|
|
await k.tween(0, 0.7, 0.3, (v) => { darkness.opacity = v })
|
|
atk.play('special'); sfxSpecial()
|
|
// Rain of fire across entire screen
|
|
for (let wave = 0; wave < 3; wave++) {
|
|
for (let i = 0; i < 6; i++) {
|
|
const rx = Math.random() * W
|
|
const meteor = k.add([k.circle(6 + Math.random() * 8), k.pos(rx, -20), k.color(safeColor(k,wave === 2 ? '#ffffff' : '#ff6600')), k.opacity(0.9), k.z(26)])
|
|
k.tween(meteor.pos.y, GROUND_Y, 0.2 + Math.random() * 0.1, (v) => { meteor.pos.y = v }, k.easings.easeInQuad)
|
|
.then(() => { sfxBonk(); spawnSparks(rx, GROUND_Y - 5, 4, '#ff6600'); meteor.destroy() })
|
|
}
|
|
k.shake(10 + wave * 5)
|
|
await k.wait(0.15)
|
|
}
|
|
// Final mega explosion
|
|
darkness.destroy()
|
|
sfxExplosion(); sfxCritical(); k.shake(35); screenFlash('#ffffff', 0.4)
|
|
def.play('knockback')
|
|
for (let i = 0; i < 6; i++) {
|
|
spawnShockwave(W * (i / 5), GROUND_Y, ['#ff2d2d', '#ff6600', '#ffe14d', '#ffffff', '#ff2d7b', '#b83dff'][i])
|
|
}
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 40, '#ffe14d')
|
|
glitchRGB(0.3)
|
|
await k.wait(0.5); atk.play('idle')
|
|
}
|
|
|
|
async function ultimateOmegaBeam(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Charge up, fire a massive full-width beam that covers half the screen
|
|
atk.play('special'); sfxSpecial()
|
|
// Charge particles converging on attacker
|
|
for (let i = 0; i < 10; i++) {
|
|
const px = atk.pos.x + (Math.random() - 0.5) * 200
|
|
const py = atk.pos.y - 30 + (Math.random() - 0.5) * 100
|
|
const p = k.add([k.circle(3), k.pos(px, py), k.color(safeColor(k,'#ffe14d')), k.opacity(0.8), k.z(15)])
|
|
k.tween(p.pos.x, atk.pos.x + dir * 15, 0.2, (v) => { p.pos.x = v })
|
|
k.tween(p.pos.y, atk.pos.y - 25, 0.2, (v) => { p.pos.y = v }).then(() => p.destroy())
|
|
}
|
|
await k.wait(0.3)
|
|
sfxZoomWhoosh()
|
|
// Massive beam
|
|
const beamW = W
|
|
const beamH = 40
|
|
const beam = k.add([k.rect(beamW, beamH), k.pos(atk.pos.x, atk.pos.y - 25 - beamH / 2), k.color(safeColor(k,'#ffe14d')), k.opacity(0.9), k.z(22)])
|
|
const beamCore = k.add([k.rect(beamW, beamH / 2), k.pos(atk.pos.x, atk.pos.y - 25 - beamH / 4), k.color(safeColor(k,'#ffffff')), k.opacity(0.7), k.z(23)])
|
|
sfxExplosion(); k.shake(20)
|
|
await k.wait(0.1)
|
|
def.play('knockback')
|
|
spawnSparks(def.pos.x, def.pos.y - 20, 20, '#ffe14d')
|
|
k.shake(25)
|
|
await k.wait(0.2)
|
|
// Beam fades
|
|
await k.tween(0.9, 0, 0.2, (v) => { beam.opacity = v; beamCore.opacity = v * 0.7 })
|
|
beam.destroy(); beamCore.destroy()
|
|
screenFlash('#ffe14d', 0.15)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffe14d')
|
|
await k.wait(0.2); atk.play('idle')
|
|
}
|
|
|
|
async function ultimateBeyondTheScreen(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Attacker punches defender so hard they "break" off-screen, then gravity brings them back
|
|
const contactX = origDX - dir * 45
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('special'); sfxCritical()
|
|
k.shake(30); screenFlash('#ffffff', 0.2)
|
|
// Defender flies off screen
|
|
def.play('knockback')
|
|
await Promise.all([
|
|
k.tween(def.pos.x, def.pos.x + dir * W, 0.2, (v) => { def.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.y, -100, 0.2, (v) => { def.pos.y = v }, k.easings.easeOutQuad),
|
|
])
|
|
def.opacity = 0
|
|
sfxExplosion()
|
|
glitchRGB(0.2)
|
|
await k.wait(0.5)
|
|
// Defender falls back from top
|
|
def.pos.x = origDX + (Math.random() - 0.5) * 40
|
|
def.pos.y = -80
|
|
def.opacity = 1
|
|
sfxZoomWhoosh()
|
|
await k.tween(def.pos.y, GROUND_Y, 0.25, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); k.shake(12)
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ff2d2d')
|
|
spawnSparks(def.pos.x, GROUND_Y - 10, 15, '#ff2d2d')
|
|
await k.tween(def.pos.x, origDX, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.tween(atk.pos.x, origAX, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function ultimateMatrixDodge(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Matrix-style slow-mo: attacker fires 3 projectiles, "dodges" in slo-mo, then counters
|
|
atk.play('special')
|
|
// Fire 3 slow projectiles
|
|
for (let i = 0; i < 3; i++) {
|
|
await spawnProjectile(atk.pos.x + dir * 20, atk.pos.y - 25 + i * 10, def.pos.x, def.pos.y - 20 + i * 10, '#ffe14d', 8)
|
|
sfxGunshot()
|
|
await k.wait(0.1)
|
|
}
|
|
// Pause
|
|
scanlineGlitch(0.08)
|
|
await k.wait(0.15)
|
|
// Teleport behind
|
|
sfxZap()
|
|
atk.pos.x = origDX + dir * 30
|
|
atk.scale.x = -atk.scale.x
|
|
atk.play('special')
|
|
// Counter attack combo
|
|
for (let i = 0; i < 4; i++) {
|
|
sfxPunch(); def.play('hit'); k.shake(5 + i * 2)
|
|
spawnSparks(def.pos.x, def.pos.y - 20 - i * 8, 5, '#00f0ff')
|
|
await k.wait(0.05)
|
|
}
|
|
sfxCritical(); sfxExplosion(); k.shake(22); screenFlash('#00f0ff', 0.2)
|
|
def.play('knockback')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#00f0ff')
|
|
atk.scale.x = -atk.scale.x
|
|
await k.tween(atk.pos.x, origAX, 0.25, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function ultimateWorldEnder(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// The ultimate ultimate: 3-phase attack — air combo, ground slam, finishing beam
|
|
// Phase 1: Launch defender into air
|
|
const contactX = origDX - dir * 45
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('attack'); sfxPunch(); def.play('hit')
|
|
await k.tween(def.pos.y, GROUND_Y - 120, 0.15, (v) => { def.pos.y = v }, k.easings.easeOutQuad)
|
|
// Phase 2: Air combo
|
|
await k.tween(atk.pos.y, GROUND_Y - 100, 0.1, (v) => { atk.pos.y = v }, k.easings.easeOutQuad)
|
|
for (let i = 0; i < 6; i++) {
|
|
atk.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxPunch(); def.play('hit'); k.shake(4 + i)
|
|
spawnSparks(def.pos.x, def.pos.y - 10, 4, `hsl(${i * 60}, 100%, 60%)`)
|
|
def.pos.y -= 5
|
|
await k.wait(0.04)
|
|
}
|
|
// Phase 3: Slam down together
|
|
atk.play('special'); sfxZoomWhoosh()
|
|
await Promise.all([
|
|
k.tween(atk.pos.y, GROUND_Y, 0.12, (v) => { atk.pos.y = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.y, GROUND_Y, 0.12, (v) => { def.pos.y = v }, k.easings.easeInQuad),
|
|
])
|
|
sfxExplosion(); sfxCritical(); k.shake(35); screenFlash('#ffffff', 0.3)
|
|
def.play('knockback')
|
|
// Massive aftermath
|
|
for (let i = 0; i < 8; i++) {
|
|
setTimeout(() => {
|
|
spawnShockwave(def.pos.x + (Math.random() - 0.5) * 100, GROUND_Y, ['#ff2d7b', '#ffe14d', '#00f0ff', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i])
|
|
spawnSparks(def.pos.x + (Math.random() - 0.5) * 60, GROUND_Y - 10 - Math.random() * 40, 5, '#ffffff')
|
|
}, i * 50)
|
|
}
|
|
glitchRGB(0.4)
|
|
dimensionalShift(0.3)
|
|
await k.wait(0.5)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.x, origDX, 0.35, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
])
|
|
atk.play('idle')
|
|
}
|
|
|
|
const ultimateAbsoluteZero = makeExplosion(40, 40, '#aaeeff', 0.5, '#ffffff', true)
|
|
const ultimateSolarCannon = makeBeam('#ffe14d', 16, 5)
|
|
const ultimatePhantomStrike = makeSpin('vertical', 250, 6)
|
|
const ultimateGodPunch = makeGrab('toss', 350)
|
|
const ultimateVoidCollapse = makeExplosion(50, 50, '#220044', 0.6, '#b83dff', true)
|
|
|
|
// Tier-gated pools (mapping is at module level for pickChoreography access)
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
// THE CREATOR — Exclusive choreographies (bitcoin-themed)
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
async function bitcoinRain(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.2)
|
|
// Rain golden ₿ letters from above
|
|
const coinCount = isCritical ? 40 : 20
|
|
for (let i = 0; i < coinCount; i++) {
|
|
const cx = def.pos.x + (Math.random() - 0.5) * 140
|
|
const startY = def.pos.y - 200 - Math.random() * 120
|
|
const sz = isCritical ? 8 + Math.random() * 10 : 6 + Math.random() * 6
|
|
const coin = k.add([
|
|
k.text('₿', { size: sz }),
|
|
k.pos(cx, startY),
|
|
k.color(safeColor(k, Math.random() > 0.3 ? '#ffd700' : Math.random() > 0.5 ? '#ffee88' : '#ff8c00')),
|
|
k.opacity(0.9),
|
|
k.z(18),
|
|
k.anchor('center'),
|
|
k.rotate(Math.random() * 360),
|
|
])
|
|
const spin = (Math.random() - 0.5) * 400
|
|
coin.onUpdate(() => {
|
|
coin.pos.y += 650 * k.dt()
|
|
coin.angle += spin * k.dt()
|
|
if (coin.pos.y > def.pos.y + 10) {
|
|
coin.destroy()
|
|
}
|
|
})
|
|
if (i % 3 === 0) sfxCoin()
|
|
await k.wait(0.02)
|
|
}
|
|
await k.wait(0.15)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 20 : 10, '#ffd700')
|
|
if (isCritical) { screenFlash('#ffd700', 0.12); sfxExplosion() }
|
|
const push = dir * (isCritical ? 80 : 35)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function lightningInvoice(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxZap()
|
|
await k.wait(0.15)
|
|
// Jagged lightning bolt from top of screen to defender
|
|
const segments = 8
|
|
const topY = -50
|
|
const botY = def.pos.y - 20
|
|
for (let i = 0; i < segments; i++) {
|
|
const t = i / segments
|
|
const bx = def.pos.x + (Math.random() - 0.5) * 40
|
|
const by2 = topY + t * (botY - topY)
|
|
const bolt = k.add([
|
|
k.rect(3, Math.abs(botY - topY) / segments + 5),
|
|
k.pos(bx, by2),
|
|
k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#ffffff')),
|
|
k.opacity(0.95),
|
|
k.z(20),
|
|
])
|
|
setTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300)
|
|
}
|
|
// ₿ symbol flash at impact
|
|
const btcFlash = k.add([
|
|
k.circle(isCritical ? 20 : 12),
|
|
k.pos(def.pos.x, def.pos.y - 30),
|
|
k.color(safeColor(k, '#ffd700')),
|
|
k.opacity(0.8),
|
|
k.z(22),
|
|
])
|
|
k.tween(btcFlash.opacity, 0, 0.4, (v: number) => { btcFlash.opacity = v })
|
|
.then(() => { if (btcFlash.exists()) btcFlash.destroy() })
|
|
await k.wait(0.1)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 20 : 10)
|
|
sfxExplosion()
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ffd700')
|
|
if (isCritical) { screenFlash('#ffd700'); glitchRGB() }
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function satoshiStrike(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.1)
|
|
// Rapid-fire gold particle stream
|
|
const shotCount = isCritical ? 25 : 15
|
|
for (let i = 0; i < shotCount; i++) {
|
|
const p = k.add([
|
|
k.circle(2),
|
|
k.pos(atk.pos.x + dir * 30, atk.pos.y - 40 + (Math.random() - 0.5) * 15),
|
|
k.color(safeColor(k, i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')),
|
|
k.opacity(0.9),
|
|
k.z(16),
|
|
])
|
|
const speed = 800 + Math.random() * 400
|
|
p.onUpdate(() => {
|
|
p.pos.x += dir * speed * k.dt()
|
|
p.opacity -= 1.5 * k.dt()
|
|
if (p.opacity <= 0 || Math.abs(p.pos.x - atk.pos.x) > 500) p.destroy()
|
|
})
|
|
if (i % 3 === 0) sfxCoin()
|
|
if (i % 5 === 0) {
|
|
def.play('hit')
|
|
k.shake(2)
|
|
spawnSparks(def.pos.x, def.pos.y - 25 - Math.random() * 20, 3, '#ffd700')
|
|
}
|
|
await k.wait(0.025)
|
|
}
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 12 : 5)
|
|
if (isCritical) { screenFlash('#ffd700', 0.08); sfxCritical() }
|
|
const push = dir * (isCritical ? 70 : 30)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function hashRateOverload(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Screen fills with scrolling hash text (simulated with falling green/gold rects)
|
|
const hashCount = isCritical ? 40 : 20
|
|
for (let i = 0; i < hashCount; i++) {
|
|
const hx = Math.random() * k.width()
|
|
const hy = Math.random() * k.height()
|
|
const h = k.add([
|
|
k.rect(8 + Math.random() * 12, 2),
|
|
k.pos(hx, hy),
|
|
k.color(safeColor(k, Math.random() > 0.5 ? '#00ff41' : '#ffd700')),
|
|
k.opacity(0.6),
|
|
k.z(25),
|
|
])
|
|
h.onUpdate(() => {
|
|
h.pos.y += 200 * k.dt()
|
|
h.opacity -= 1.5 * k.dt()
|
|
if (h.opacity <= 0) h.destroy()
|
|
})
|
|
}
|
|
await k.wait(0.5)
|
|
// Explosion
|
|
screenFlash(isCritical ? '#ffd700' : '#00ff41', 0.15)
|
|
sfxExplosion()
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 25 : 12)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 12, '#ffd700')
|
|
if (isCritical) glitchRGB()
|
|
const push = dir * (isCritical ? 100 : 50)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function blockchainSlam(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
const contactX = origDX - dir * 60
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.x, contactX, 0.12, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('attack')
|
|
// Chain of blocks swinging as weapon
|
|
const blocks: any[] = []
|
|
const blockCount = isCritical ? 6 : 4
|
|
for (let i = 0; i < blockCount; i++) {
|
|
const bk = k.add([
|
|
k.rect(10, 10),
|
|
k.pos(atk.pos.x + dir * (i * 14), atk.pos.y - 40),
|
|
k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#c8a000')),
|
|
k.opacity(0.9),
|
|
k.z(17),
|
|
])
|
|
blocks.push(bk)
|
|
}
|
|
// Swing chain toward defender
|
|
for (let i = 0; i < blockCount; i++) {
|
|
await k.tween(blocks[i].pos.x, def.pos.x + (i - blockCount / 2) * 5, 0.08, (v: number) => { blocks[i].pos.x = v }, k.easings.easeOutQuad)
|
|
await k.tween(blocks[i].pos.y, def.pos.y - 30, 0.04, (v: number) => { blocks[i].pos.y = v })
|
|
}
|
|
sfxPunch()
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 7)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 10, '#ffd700')
|
|
if (isCritical) { screenFlash('#ffd700', 0.1); sfxCritical() }
|
|
blocks.forEach(b => b.destroy())
|
|
const push = dir * (isCritical ? 80 : 35)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function creatorMode(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Typing effect — rapid small flashes from attacker
|
|
for (let i = 0; i < 8; i++) {
|
|
const sp = k.add([
|
|
k.circle(2),
|
|
k.pos(atk.pos.x + dir * 20, atk.pos.y - 45 + (Math.random() - 0.5) * 10),
|
|
k.color(safeColor(k, i % 2 === 0 ? '#00ff41' : '#ffd700')),
|
|
k.opacity(0.8),
|
|
k.z(16),
|
|
])
|
|
setTimeout(() => { if (sp.exists()) sp.destroy() }, 200)
|
|
await k.wait(0.05)
|
|
}
|
|
// Reality warp — defender's sprite distorts
|
|
if (def.exists()) {
|
|
const origScaleX = def.scale?.x ?? 1
|
|
const origScaleY = def.scale?.y ?? 1
|
|
for (let w = 0; w < 4; w++) {
|
|
def.scaleTo(origScaleX * (1 + (Math.random() - 0.5) * 0.5), origScaleY * (1 + (Math.random() - 0.5) * 0.5))
|
|
await k.wait(0.06)
|
|
}
|
|
def.scaleTo(origScaleX, origScaleY)
|
|
}
|
|
sfxExplosion()
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 8)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 12, '#00ff41')
|
|
if (isCritical) { screenFlash('#00ff41'); glitchRGB() }
|
|
const push = dir * (isCritical ? 70 : 30)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function morphStrike(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Flash golden — the morph is handled by the morph system
|
|
// This move itself is a quick dash-hit with gold sparks
|
|
screenFlash('#ffd700', 0.08)
|
|
sfxZoomWhoosh()
|
|
const contactX = origDX - dir * 45
|
|
await k.tween(atk.pos.x, contactX, 0.1, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad)
|
|
atk.play('attack')
|
|
sfxPunch()
|
|
await k.wait(0.08)
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 14 : 6)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 15 : 8, '#ffd700')
|
|
if (isCritical) { sfxCritical(); screenFlash('#ffd700') }
|
|
const push = dir * (isCritical ? 80 : 35)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(def.pos.x, origDX, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
atk.play('idle')
|
|
}
|
|
|
|
async function divineIntervention(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Ascend off screen
|
|
sfxZoomWhoosh()
|
|
atk.play('win')
|
|
await k.tween(atk.pos.y, atk.pos.y - 300, 0.25, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
atk.opacity = 0
|
|
// Golden glow expanding where they were
|
|
const glowX = origAX
|
|
const glow = k.add([
|
|
k.circle(5),
|
|
k.pos(glowX, GROUND_Y - 40),
|
|
k.color(safeColor(k, '#ffd700')),
|
|
k.opacity(0.6),
|
|
k.z(19),
|
|
])
|
|
await k.tween(5, 60, 0.3, (v: number) => { glow.radius = v; glow.opacity = 0.6 - v / 150 })
|
|
glow.destroy()
|
|
// Slam down onto defender from above
|
|
atk.opacity = 1
|
|
atk.pos.x = def.pos.x
|
|
atk.pos.y = -100
|
|
atk.play('kick')
|
|
sfxZoomWhoosh()
|
|
await k.tween(atk.pos.y, GROUND_Y, 0.15, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad)
|
|
// Massive impact
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 30 : 18)
|
|
screenFlash('#ffd700', 0.15)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 15, '#ffd700')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#ffd700')
|
|
if (isCritical) glitchRGB()
|
|
def.play('knockback')
|
|
const push = dir * (isCritical ? 120 : 60)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.5)
|
|
await Promise.all([
|
|
k.tween(atk.pos.x, origAX, 0.25, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(atk.pos.y, GROUND_Y, 0.01, (v: number) => { atk.pos.y = v }),
|
|
k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
atk.play('idle')
|
|
}
|
|
|
|
// ── Creator ₿ Swarm Variations ──
|
|
|
|
async function bitcoinSwarm(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Swarm of ₿ letters fly from creator toward defender with zigzag paths
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.15)
|
|
const total = isCritical ? 30 : 18
|
|
const swarmThings: any[] = []
|
|
for (let i = 0; i < total; i++) {
|
|
const sx = atk.pos.x + dir * 10 + (Math.random() - 0.5) * 30
|
|
const sy = atk.pos.y - 50 + (Math.random() - 0.5) * 60
|
|
const sz = 6 + Math.random() * 8
|
|
const colors = ['#ffd700', '#ffee88', '#ff8c00', '#c8a000']
|
|
const p = k.add([
|
|
k.text('₿', { size: sz }),
|
|
k.pos(sx, sy),
|
|
k.color(safeColor(k, colors[i % colors.length])),
|
|
k.opacity(0.9),
|
|
k.z(16),
|
|
k.anchor('center'),
|
|
k.rotate(Math.random() * 360),
|
|
])
|
|
const tx = def.pos.x + (Math.random() - 0.5) * 30
|
|
const ty = def.pos.y - 30 + (Math.random() - 0.5) * 40
|
|
const zigFreq = 4 + Math.random() * 4
|
|
const zigAmp = 12 + Math.random() * 15
|
|
const spinRate = (Math.random() - 0.5) * 600
|
|
swarmThings.push(p)
|
|
k.tween(0, 1, 0.3 + Math.random() * 0.15, (t) => {
|
|
p.pos.x = sx + (tx - sx) * t
|
|
p.pos.y = sy + (ty - sy) * t + Math.sin(t * Math.PI * zigFreq) * zigAmp
|
|
p.angle += spinRate * k.dt()
|
|
p.opacity = 0.9 - t * 0.3
|
|
}).then(() => { if (p.exists()) { spawnSparks(p.pos.x, p.pos.y, 2, '#ffd700'); p.destroy() } })
|
|
if (i % 3 === 0) sfxCoin()
|
|
await k.wait(0.02)
|
|
}
|
|
await k.wait(0.25)
|
|
sfxBonk()
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 18 : 8)
|
|
if (isCritical) { screenFlash('#ffd700', 0.1); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 18 : 10, '#ffd700')
|
|
const push = dir * (isCritical ? 90 : 40)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
swarmThings.forEach(s => { if (s.exists()) s.destroy() })
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function satoshiTornado(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// ₿ letters spiral around the defender in a tightening vortex then explode
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
sfxZoomWhoosh()
|
|
const total = isCritical ? 24 : 14
|
|
const vortex: any[] = []
|
|
for (let i = 0; i < total; i++) {
|
|
const sz = 7 + Math.random() * 7
|
|
const colors = ['#ffd700', '#ffee88', '#ff8c00', '#ffffff']
|
|
const p = k.add([
|
|
k.text('₿', { size: sz }),
|
|
k.pos(def.pos.x, def.pos.y - 30),
|
|
k.color(safeColor(k, colors[i % colors.length])),
|
|
k.opacity(0.85),
|
|
k.z(18),
|
|
k.anchor('center'),
|
|
k.rotate(0),
|
|
])
|
|
vortex.push(p)
|
|
const baseAngle = (i / total) * Math.PI * 2
|
|
const startRadius = 80 + Math.random() * 30
|
|
p.onUpdate(() => {
|
|
const elapsed = k.time()
|
|
const shrink = Math.max(5, startRadius - elapsed * 40)
|
|
const a = baseAngle + elapsed * (6 + i * 0.3)
|
|
p.pos.x = def.pos.x + Math.cos(a) * shrink
|
|
p.pos.y = def.pos.y - 30 + Math.sin(a) * shrink * 0.6
|
|
p.angle = elapsed * 200 + i * 45
|
|
p.opacity = 0.6 + Math.sin(elapsed * 8 + i) * 0.3
|
|
})
|
|
await k.wait(0.04)
|
|
}
|
|
// Let the tornado spin for a moment
|
|
await k.wait(0.7)
|
|
// Explode outward
|
|
screenFlash('#ffd700', 0.12)
|
|
sfxExplosion()
|
|
k.shake(isCritical ? 22 : 10)
|
|
vortex.forEach((p, i) => {
|
|
if (!p.exists()) return
|
|
const angle = (i / total) * Math.PI * 2
|
|
const dist = 100 + Math.random() * 60
|
|
k.tween(0, 1, 0.3, (t) => {
|
|
p.pos.x = def.pos.x + Math.cos(angle) * dist * t
|
|
p.pos.y = def.pos.y - 30 + Math.sin(angle) * dist * 0.6 * t
|
|
p.opacity = 1 - t
|
|
}).then(() => { if (p.exists()) p.destroy() })
|
|
})
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
if (isCritical) { sfxCritical(); glitchRGB() }
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 20 : 10, '#ffd700')
|
|
const push = dir * (isCritical ? 100 : 45)
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.4)
|
|
atk.play('idle')
|
|
vortex.forEach(p => { if (p.exists()) p.destroy() })
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function hodlWave(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Wall of ₿ letters advancing across the screen like a tidal wave
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.1)
|
|
const rows = isCritical ? 6 : 4
|
|
const cols = isCritical ? 10 : 7
|
|
const wave: any[] = []
|
|
const startX = atk.pos.x + dir * 25
|
|
const screenTop = -20
|
|
const screenBot = GROUND_Y + 10
|
|
for (let c = 0; c < cols; c++) {
|
|
for (let r = 0; r < rows; r++) {
|
|
const sz = 8 + Math.random() * 6
|
|
const colors = ['#ffd700', '#ffee88', '#ff8c00', '#c8a000', '#ffffff']
|
|
const py = screenTop + (screenBot - screenTop) * (r / rows) + (Math.random() - 0.5) * 20
|
|
const p = k.add([
|
|
k.text('₿', { size: sz }),
|
|
k.pos(startX + c * dir * 8, py),
|
|
k.color(safeColor(k, colors[(r + c) % colors.length])),
|
|
k.opacity(0.85),
|
|
k.z(17),
|
|
k.anchor('center'),
|
|
k.rotate(Math.random() * 40 - 20),
|
|
])
|
|
wave.push(p)
|
|
}
|
|
// Stagger each column
|
|
const targetX = def.pos.x + dir * (10 + c * 3)
|
|
wave.slice(-rows).forEach((p) => {
|
|
const sx = p.pos.x
|
|
k.tween(0, 1, 0.35 + c * 0.04, (t) => {
|
|
p.pos.x = sx + (targetX - sx) * t
|
|
p.angle += 120 * k.dt()
|
|
p.pos.y += Math.sin(t * Math.PI * 3) * 2
|
|
}).then(() => { if (p.exists()) { spawnSparks(p.pos.x, p.pos.y, 1, '#ffd700'); p.destroy() } })
|
|
})
|
|
if (c % 2 === 0) sfxCoin()
|
|
await k.wait(0.04)
|
|
}
|
|
await k.wait(0.3)
|
|
sfxExplosion()
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 20 : 9)
|
|
if (isCritical) { screenFlash('#ffd700', 0.12); glitchRGB(0.2) }
|
|
spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 22 : 12, '#ffd700')
|
|
const push = dir * (isCritical ? 95 : 42)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.35)
|
|
atk.play('idle')
|
|
wave.forEach(p => { if (p.exists()) p.destroy() })
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function bitcoinBarrage(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
// Rapid-fire ₿ letters shot from the creator's laptop like bullets
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.1)
|
|
const shotCount = isCritical ? 35 : 20
|
|
for (let i = 0; i < shotCount; i++) {
|
|
const sz = 5 + Math.random() * 8
|
|
const colors = ['#ffd700', '#ffee88', '#ff8c00', '#c8a000']
|
|
const spreadY = (Math.random() - 0.5) * (15 + i * 0.5)
|
|
const p = k.add([
|
|
k.text('₿', { size: sz }),
|
|
k.pos(atk.pos.x + dir * 25, atk.pos.y - 42 + spreadY),
|
|
k.color(safeColor(k, colors[i % colors.length])),
|
|
k.opacity(0.9),
|
|
k.z(16),
|
|
k.anchor('center'),
|
|
k.rotate(Math.random() * 360),
|
|
])
|
|
const speed = 900 + Math.random() * 500
|
|
const spinRate = (Math.random() - 0.5) * 800
|
|
p.onUpdate(() => {
|
|
p.pos.x += dir * speed * k.dt()
|
|
p.angle += spinRate * k.dt()
|
|
p.opacity -= 1.2 * k.dt()
|
|
if (p.opacity <= 0 || Math.abs(p.pos.x - atk.pos.x) > 500) p.destroy()
|
|
})
|
|
if (i % 2 === 0) sfxCoin()
|
|
if (i % 4 === 0) {
|
|
def.play('hit')
|
|
k.shake(2)
|
|
spawnSparks(def.pos.x, def.pos.y - 25 - Math.random() * 20, 3, '#ffd700')
|
|
}
|
|
await k.wait(0.018)
|
|
}
|
|
def.play(isCritical ? 'knockback' : 'hit')
|
|
k.shake(isCritical ? 15 : 6)
|
|
if (isCritical) { screenFlash('#ffd700', 0.1); sfxCritical() }
|
|
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 18 : 8, '#ffd700')
|
|
const push = dir * (isCritical ? 80 : 35)
|
|
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// ── Creator Ultimates ──
|
|
|
|
async function ultimateGenesisBlock(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Screen goes dark, massive genesis block materializes and detonates
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
const darkness = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(30)])
|
|
await k.tween(darkness.opacity, 0.7, 0.3, (v: number) => { darkness.opacity = v })
|
|
// Genesis block appears at center
|
|
const block = k.add([
|
|
k.rect(60, 60),
|
|
k.pos(k.width() / 2 - 30, k.height() / 2 - 30),
|
|
k.color(safeColor(k, '#ffd700')),
|
|
k.opacity(0),
|
|
k.z(35),
|
|
])
|
|
await k.tween(block.opacity, 1, 0.3, (v: number) => { block.opacity = v })
|
|
await k.wait(0.3)
|
|
// Detonate!
|
|
sfxExplosion()
|
|
block.destroy()
|
|
darkness.destroy()
|
|
screenFlash('#ffd700', 0.2)
|
|
k.shake(35)
|
|
glitchRGB()
|
|
spawnShockwave(k.width() / 2, k.height() / 2, '#ffd700')
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700')
|
|
def.play('knockback')
|
|
const push = dir * 120
|
|
k.tween(def.pos.x, origDX + push, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.6)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function ultimateTheHalving(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Screen splits horizontally with a golden line
|
|
atk.play('special')
|
|
sfxZap()
|
|
const line = k.add([
|
|
k.rect(k.width(), 3),
|
|
k.pos(0, k.height() / 2),
|
|
k.color(safeColor(k, '#ffd700')),
|
|
k.opacity(0),
|
|
k.z(32),
|
|
])
|
|
await k.tween(line.opacity, 1, 0.15, (v: number) => { line.opacity = v })
|
|
screenFlash('#ffd700', 0.1)
|
|
glitchRGB()
|
|
sfxExplosion()
|
|
k.shake(25)
|
|
// Briefly scale defender to half
|
|
if (def.exists()) {
|
|
const origSY = def.scale?.y ?? 1
|
|
def.scaleTo(def.scale?.x ?? 1, origSY * 0.5)
|
|
await k.wait(0.2)
|
|
def.scaleTo(def.scale?.x ?? 1, origSY)
|
|
}
|
|
line.destroy()
|
|
def.play('knockback')
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ffd700')
|
|
const push = dir * 100
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.5)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function ultimateFullNodeCrush(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Giant server rack drops from above
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
await k.wait(0.3)
|
|
const rack = k.add([
|
|
k.rect(40, 80),
|
|
k.pos(def.pos.x - 20, -100),
|
|
k.color(safeColor(k, '#333333')),
|
|
k.opacity(0.9),
|
|
k.z(28),
|
|
])
|
|
// LEDs on the rack
|
|
for (let led = 0; led < 6; led++) {
|
|
k.add([
|
|
k.circle(1),
|
|
k.pos(rack.pos.x + 5 + led * 5, rack.pos.y + 10),
|
|
k.color(safeColor(k, led % 2 === 0 ? '#00ff41' : '#ff0000')),
|
|
k.opacity(0.8),
|
|
k.z(29),
|
|
])
|
|
}
|
|
sfxZoomWhoosh()
|
|
await k.tween(rack.pos.y, def.pos.y - 80, 0.2, (v: number) => { rack.pos.y = v }, k.easings.easeInQuad)
|
|
// Impact
|
|
sfxExplosion()
|
|
k.shake(30)
|
|
screenFlash('#ffffff', 0.15)
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffd700')
|
|
spawnShockwave(def.pos.x, GROUND_Y, '#333333')
|
|
rack.destroy()
|
|
def.play('knockback')
|
|
const push = dir * 100
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.6)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
async function ultimateSatoshiVision(atk: Fighter, def: Fighter, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
|
|
// Everything turns gold — defender trapped in bitcoin block
|
|
atk.play('special')
|
|
sfxSpecial()
|
|
// Gold overlay
|
|
const overlay = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(30)])
|
|
await k.tween(overlay.opacity, 0.4, 0.3, (v: number) => { overlay.opacity = v })
|
|
// Wireframe box around defender
|
|
const bx = def.pos.x - 30
|
|
const by2 = def.pos.y - 70
|
|
const bw = 60
|
|
const bh2 = 80
|
|
const top = k.add([k.rect(bw, 2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
|
|
const bot = k.add([k.rect(bw, 2), k.pos(bx, by2 + bh2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
|
|
const left = k.add([k.rect(2, bh2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
|
|
const right = k.add([k.rect(2, bh2), k.pos(bx + bw, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
|
|
await k.wait(0.3)
|
|
// Crush — shrink box
|
|
sfxZoomWhoosh()
|
|
await Promise.all([
|
|
k.tween(bx, def.pos.x - 5, 0.3, (v: number) => { left.pos.x = v; top.pos.x = v; bot.pos.x = v }),
|
|
k.tween(bx + bw, def.pos.x + 5, 0.3, (v: number) => { right.pos.x = v }),
|
|
])
|
|
sfxExplosion()
|
|
k.shake(30)
|
|
screenFlash('#ffffff', 0.15)
|
|
glitchRGB();
|
|
[top, bot, left, right].forEach(b => b.destroy())
|
|
overlay.destroy()
|
|
spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700')
|
|
def.play('knockback')
|
|
const push = dir * 110
|
|
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.6)
|
|
atk.play('idle')
|
|
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
const choreographyMap: Record<string, typeof dashPunch> = {
|
|
dashPunch, aerialSlam, flyingKick, dashThrough, uppercut,
|
|
multiHit, projectile, jetpackDive, gunBurst, groundPound,
|
|
teleportStrike, fullScreenDash, lightningRush, zoomRush, rapidFlurry,
|
|
// Weapons
|
|
swordSlash, hammerSmash, laserBeam, rocketLauncher, bombThrow,
|
|
minigunSpray, sniperShot, whipCrack, katanaCombo, chainsawRev,
|
|
// Vehicles & objects
|
|
motorbikeCharge, carSmash, boatCannon, anvilDrop,
|
|
// Close-combat / grapple
|
|
pocketCannon, grappleFlurry, bodySlam, pinballCombo, suplex,
|
|
// Themed
|
|
golfClubSmash, fireBreath, riddleBarrage, cloneStrike,
|
|
coinShower, penStab, mathAttack, trapCardAttack, afterimageDash, bulletTimeAttack,
|
|
// --- NEW: Food throws ---
|
|
pizzaSlam, bananaFling, pieSmash, hotdogWhip, watermelonBomb,
|
|
sushiBarrage, burgerToss, iceCreamFling, tacoStorm, donutBarrage,
|
|
popcornBlast, cookieFling, eggBombard, baguetteStrike, coffeeSplash,
|
|
ramenWhip, nachoVolley, pretzelFling, meatballStorm, popsicleJab,
|
|
candyBarrage, waffleSlam, pancakeFrisbee, drumstickSmack, cornCobCannon,
|
|
pickleJavelin, cabbagePunt, breadLoafBash, cheeseWheel, fishSlap,
|
|
// --- NEW: Weapon swings ---
|
|
baseballBat, fryingPan, rollingPin, umbrellaWhack, mopSwipe,
|
|
broomStrike, wrenchSmash, crowbarSwing, plungerSlam, tennisRacket,
|
|
hockeyStick, poolCue, guitarSmash, shovelBash, pickaxeStrike,
|
|
oarSwing, flagpoleSmash, caneWhack, rulerSlap, newspaperRoll,
|
|
// --- NEW: Projectile streams ---
|
|
tennisBallVolley, baseballPitch, bowlingBallRoll, rubberDuckFlood,
|
|
shoeBarrage, bookStorm, phoneFling, keyboardSmash, diceBarrage,
|
|
marbleShower, snowballFight, paintCanBlast, tomatoBarrage,
|
|
waterBalloonRain, pillowFight, vinylRecordFling, frisbeeToss,
|
|
beachBallBonk, soccerKick, basketballDunk,
|
|
// --- NEW: Drop attacks ---
|
|
pianoDrop, safeDrop, fridgeDrop, couchDrop, tvDrop, toiletDrop,
|
|
bathtubDrop, washerDrop, vendingDrop, chandelierDrop, boulderDrop,
|
|
satelliteDrop, ufoDrop, giantShoeDrop, giantPhoneDrop,
|
|
// --- NEW: Vehicles ---
|
|
shoppingCart, forkliftCharge, tankRoll, helicopterStrike,
|
|
airplaneSwoop, rocketRide, unicycleRun, zamboniCrush, tractorPlow, golfCartDrive,
|
|
// --- NEW: Spin attacks ---
|
|
tornadoSpin, backflipKick, corkscrewDive, helicopterArms, breakdanceSweep,
|
|
cartwheelStrike, frontflipSlam, drillSpin, cycloneKick, pirouetteStrike,
|
|
// --- NEW: Flip attacks ---
|
|
doubleBackflip, wallBounce, springboardLaunch, barrelRoll,
|
|
trampolineBounce, poleVault, skateTrick, surfSlam, rolloutBall, aerialTwist,
|
|
// --- NEW: Beam attacks ---
|
|
kamehameha, freezeRay, heatVision, plasmaBeam, rainbowBeam,
|
|
shadowBeam, sonicWave, gravityBeam, mindBlast, pixelBeam,
|
|
glitchBeam, bassDropBeam, wifiBlast, dataStream, chainLightning,
|
|
// --- NEW: Animal summons ---
|
|
sharkBite, bearSwipe, eagleDive, snakeLunge, bullCharge,
|
|
gorillaSlam, scorpionSting, crabPinch, batSwarm, wolfPack,
|
|
spiderWeb, beeSwarm, catScratch, dogPile, dolphinFlip,
|
|
// --- NEW: Explosion attacks ---
|
|
dynamiteBlast, c4Detonation, fireworksBurst, nukeStrike, volcanoErupt,
|
|
grenadeBlast, partyPopper, pinataSmash, balloonPop, glitterBomb,
|
|
smokeBomb, flashBang, cherryBomb, confettiCannon, stinkBomb,
|
|
// --- NEW: Silly/Meme ---
|
|
rubberChicken, airHornBlast, vuvuzelaBlast, selfieStrike,
|
|
dabAttack, flossAttack, yeetThrow, tPoseAssert, emojiBarrage,
|
|
memeBeam, ratioAttack, capThrow, fingerGuns, micDrop,
|
|
dramaticEntrance, clapback, touchGrass, noScope,
|
|
// --- NEW: Grapple/Wrestling ---
|
|
piledriver, powerbomb, ddt, germanSuplex, chokeslam,
|
|
tombstone, stunner, rko, spear, clothesline,
|
|
atomicDrop, hurricanrana, moonsault, elbowDrop, frogSplash,
|
|
// --- NEW: Energy/Magic ---
|
|
fireball, iceLance, thunderStrike, darkVoid, solarFlare,
|
|
spiritBomb, windSlash, earthquakeStrike, portalPunch,
|
|
gravityWell, plasmaOrb, voidRift, crystalShards, meteorStrike,
|
|
arcaneBarrage, holySmite, hexCurse, bloodMoon, shadowClone,
|
|
// --- NEW: Tech/Modern ---
|
|
droneStrike, satelliteLaser, hackerAttack, bugSwarm,
|
|
stackOverflow, segfault, blueScreen, fourOhFour, ctrlAltDelete,
|
|
bitcoinCrash, nftRugPull, aiUprising, captchaTrap, popupSpam, malwareInject,
|
|
// --- NEW: Music ---
|
|
bassDrop, guitarSolo, drumSolo, dubstepCannon, airGuitar,
|
|
karaokeAttack, beatboxBlast, vinylScratch,
|
|
// --- NEW: Sports ---
|
|
homeRunSwing, slapShot, servingAce, fieldGoalKick, bodyCheck,
|
|
dropKick, elbowStrike, kneeStrike, headbutt, shoulderCharge,
|
|
// --- NEW: Nature/Elemental ---
|
|
treeSmash, icebergDrop, lavaSplash, tornadoFling, tsunamiWave,
|
|
avalanche, vineWhip, sandstorm, thornBarrage, pollenCloud,
|
|
// --- NEW: Space/Sci-fi ---
|
|
blackHole, photonTorpedo, tractor_beam, warpDrive, alienAbduction,
|
|
laserSword, ionCannon, plasmaSword, cosmicRay, asteroidBelt,
|
|
// --- NEW: Medieval/Fantasy ---
|
|
dragonBreath, maceSwing, crossbowBolt, shieldBash, flailSwing,
|
|
battleAxe, holyWater, potionThrow, scrollBlast, enchantedArrow,
|
|
// --- TIER-GATED ULTIMATES ---
|
|
// Tier 2+
|
|
ultimateRapidBarrage, ultimateOrbitalStrike, ultimateCloneSurround, ultimateGatlingFury, ultimateEarthquake,
|
|
ultimateBouncingFury, ultimateTripleBeam, ultimateMegaThrow, ultimateTornadoGrab, ultimateMeteorShower,
|
|
// Tier 3+
|
|
ultimateScreenNuke, ultimateBlackHoleVortex, ultimateDimensionalRift, ultimateTimeSlow, ultimateSummonArmy,
|
|
ultimateHellfireRain, ultimateCrystalCage, ultimateGigaDrill, ultimateSuperSlam, ultimateAvalancheDrop, ultimateBulletTimeBarrage,
|
|
// Tier 4+
|
|
ultimateFinaleRush, ultimateMeteorCrash, ultimateScreenShatter, ultimateGravityReverse, ultimateInfiniteCombo,
|
|
ultimatePlasmaStorm, ultimateFrozenCoffin, ultimateLavaGeyser, ultimateChainGrab, ultimateRocketBarrage,
|
|
// Tier 5+
|
|
ultimateArmageddon, ultimateOmegaBeam, ultimateBeyondTheScreen, ultimateMatrixDodge, ultimateWorldEnder,
|
|
ultimateAbsoluteZero, ultimateSolarCannon, ultimatePhantomStrike, ultimateGodPunch, ultimateVoidCollapse,
|
|
// --- THE CREATOR: Bitcoin-themed moves ---
|
|
bitcoinRain, lightningInvoice, satoshiStrike, hashRateOverload, blockchainSlam, creatorMode,
|
|
morphStrike, divineIntervention,
|
|
bitcoinSwarm, satoshiTornado, hodlWave, bitcoinBarrage,
|
|
// --- THE CREATOR: Exclusive ultimates ---
|
|
ultimateGenesisBlock, ultimateTheHalving, ultimateFullNodeCrush, ultimateSatoshiVision,
|
|
}
|
|
|
|
// === MORPH / COSTUME SYSTEM ===
|
|
// 3 morph types per character: elemental, mech, and beast
|
|
// Each morph temporarily transforms the fighter with visual overlays, aura, and color shift
|
|
|
|
interface MorphOverlay { obj: any; type: string }
|
|
let activeMorphs: MorphOverlay[] = []
|
|
|
|
function destroyMorphOverlays() {
|
|
activeMorphs.forEach(m => { if (m.obj.exists()) m.obj.destroy() })
|
|
activeMorphs = []
|
|
}
|
|
|
|
// Morph 0: ELEMENTAL — fire/ice/electric aura wraps around the fighter
|
|
function applyElementalMorph(fighter: any, side: 'a' | 'b') {
|
|
const seed = side === 'a' ? botA.seed : botB.seed
|
|
let h = 0
|
|
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
|
|
const element = Math.abs(h) % 3 // 0=fire, 1=ice, 2=electric
|
|
|
|
const colors = [
|
|
['#ff4400', '#ff8800', '#ffcc00'], // fire
|
|
['#44ccff', '#88eeff', '#ffffff'], // ice
|
|
['#ffff00', '#88ff00', '#00ffff'], // electric
|
|
][element]
|
|
|
|
// Aura glow behind fighter
|
|
const aura = k.add([
|
|
k.circle(45 + Math.random() * 10),
|
|
k.pos(fighter.pos.x, fighter.pos.y - 20),
|
|
k.color(safeColor(k,colors[0])),
|
|
k.opacity(0.15),
|
|
k.z(fighter.z - 1),
|
|
k.anchor('center'),
|
|
k.scale(1),
|
|
])
|
|
aura.onUpdate(() => {
|
|
aura.pos.x = fighter.pos.x
|
|
aura.pos.y = fighter.pos.y - 20
|
|
aura.opacity = 0.1 + Math.sin(k.time() * 8) * 0.08
|
|
const s = 1 + Math.sin(k.time() * 6) * 0.15
|
|
aura.scale.x = s; aura.scale.y = s
|
|
})
|
|
activeMorphs.push({ obj: aura, type: 'elemental' })
|
|
|
|
// Orbiting particles
|
|
for (let i = 0; i < 6; i++) {
|
|
const p = k.add([
|
|
k.circle(2 + Math.random() * 2),
|
|
k.pos(fighter.pos.x, fighter.pos.y),
|
|
k.color(safeColor(k,colors[1 + (i % 2)])),
|
|
k.opacity(0.6),
|
|
k.z(fighter.z + 1),
|
|
k.anchor('center'),
|
|
])
|
|
const angle = (i / 6) * Math.PI * 2
|
|
const radius = 30 + Math.random() * 15
|
|
p.onUpdate(() => {
|
|
const a = angle + k.time() * (3 + i * 0.5)
|
|
p.pos.x = fighter.pos.x + Math.cos(a) * radius
|
|
p.pos.y = fighter.pos.y - 20 + Math.sin(a) * radius * 0.6
|
|
p.opacity = 0.4 + Math.sin(k.time() * 10 + i) * 0.3
|
|
})
|
|
activeMorphs.push({ obj: p, type: 'elemental' })
|
|
}
|
|
|
|
// Color tint the fighter
|
|
fighter.color = safeColor(k,colors[0])
|
|
fighter.opacity = 0.9
|
|
}
|
|
|
|
// Morph 1: MECH — armor plates, wings/jets, metallic overlay
|
|
function applyMechMorph(fighter: any, _side: 'a' | 'b') {
|
|
const dir = fighter.scale.x > 0 ? 1 : -1
|
|
|
|
// Shoulder armor plates
|
|
for (let s = 0; s < 2; s++) {
|
|
const sDir = s === 0 ? -1 : 1
|
|
const shoulder = k.add([
|
|
k.rect(12, 8), k.pos(fighter.pos.x + sDir * 25 * dir, fighter.pos.y - 30),
|
|
k.color(safeColor(k,'#888899')), k.opacity(0.85), k.z(fighter.z + 1), k.anchor('center'),
|
|
])
|
|
shoulder.onUpdate(() => {
|
|
shoulder.pos.x = fighter.pos.x + sDir * 25 * dir
|
|
shoulder.pos.y = fighter.pos.y - 30 + Math.sin(k.time() * 4) * 2
|
|
})
|
|
activeMorphs.push({ obj: shoulder, type: 'mech' })
|
|
}
|
|
|
|
// Jet wings + flame boosters
|
|
for (let w = 0; w < 2; w++) {
|
|
const wingDir = w === 0 ? -1 : 1
|
|
const wing = k.add([
|
|
k.rect(6, 20 + w * 5), k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y - 15),
|
|
k.color(safeColor(k,'#5566aa')), k.opacity(0.7), k.z(fighter.z - 1), k.anchor('center'),
|
|
k.rotate(wingDir * 15 * dir),
|
|
])
|
|
wing.onUpdate(() => {
|
|
wing.pos.x = fighter.pos.x - 30 * dir * wingDir
|
|
wing.pos.y = fighter.pos.y - 15
|
|
})
|
|
activeMorphs.push({ obj: wing, type: 'mech' })
|
|
|
|
const flame = k.add([
|
|
k.rect(4, 8 + Math.random() * 6),
|
|
k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y + 5),
|
|
k.color(safeColor(k,'#ff6600')),
|
|
k.opacity(0.6), k.z(fighter.z - 2), k.anchor('center'),
|
|
])
|
|
flame.onUpdate(() => {
|
|
flame.pos.x = fighter.pos.x - 30 * dir * wingDir + (Math.random() - 0.5) * 3
|
|
flame.pos.y = fighter.pos.y + 5 + Math.random() * 4
|
|
flame.opacity = 0.3 + Math.random() * 0.4
|
|
flame.color = safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')
|
|
})
|
|
activeMorphs.push({ obj: flame, type: 'mech' })
|
|
}
|
|
|
|
// Visor glow
|
|
const visor = k.add([
|
|
k.rect(20, 4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 35),
|
|
k.color(safeColor(k,'#00ffaa')), k.opacity(0.7), k.z(fighter.z + 2), k.anchor('center'),
|
|
])
|
|
visor.onUpdate(() => {
|
|
visor.pos.x = fighter.pos.x + 5 * dir
|
|
visor.pos.y = fighter.pos.y - 35
|
|
visor.opacity = 0.5 + Math.sin(k.time() * 12) * 0.3
|
|
})
|
|
activeMorphs.push({ obj: visor, type: 'mech' })
|
|
|
|
// Metallic tint
|
|
fighter.color = safeColor(k,'#aabbcc')
|
|
fighter.opacity = 0.95
|
|
}
|
|
|
|
// Morph 2: BEAST — grows horns/tail/claws, goes wild
|
|
function applyBeastMorph(fighter: any, side: 'a' | 'b') {
|
|
const dir = fighter.scale.x > 0 ? 1 : -1
|
|
const seed = side === 'a' ? botA.seed : botB.seed
|
|
let h = 0
|
|
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
|
|
const beastType = Math.abs(h >> 4) % 3 // 0=demon, 1=wolf, 2=dragon
|
|
|
|
const beastColors = [
|
|
['#cc2222', '#ff4444', '#880000'], // demon
|
|
['#666688', '#aaaacc', '#444466'], // wolf
|
|
['#228844', '#44cc66', '#115533'], // dragon
|
|
][beastType]
|
|
|
|
// Horns
|
|
for (let horn = 0; horn < 2; horn++) {
|
|
const hDir = horn === 0 ? -1 : 1
|
|
const h1 = k.add([
|
|
k.rect(3, 12 + Math.random() * 6),
|
|
k.pos(fighter.pos.x + hDir * 10, fighter.pos.y - 45),
|
|
k.color(safeColor(k,beastColors[0])),
|
|
k.opacity(0.9), k.z(fighter.z + 2), k.anchor('bot'),
|
|
k.rotate(hDir * (20 + Math.random() * 15)),
|
|
])
|
|
h1.onUpdate(() => {
|
|
h1.pos.x = fighter.pos.x + hDir * 10
|
|
h1.pos.y = fighter.pos.y - 45
|
|
})
|
|
activeMorphs.push({ obj: h1, type: 'beast' })
|
|
}
|
|
|
|
// Tail
|
|
for (let s = 0; s < 5; s++) {
|
|
const seg = k.add([
|
|
k.circle(4 - s * 0.5),
|
|
k.pos(fighter.pos.x - dir * (20 + s * 8), fighter.pos.y - 5 + s * 2),
|
|
k.color(safeColor(k,beastColors[1])),
|
|
k.opacity(0.8), k.z(fighter.z - 1), k.anchor('center'),
|
|
])
|
|
seg.onUpdate(() => {
|
|
const wave = Math.sin(k.time() * 5 + s * 0.8) * (4 + s * 2)
|
|
seg.pos.x = fighter.pos.x - dir * (20 + s * 8)
|
|
seg.pos.y = fighter.pos.y - 5 + s * 2 + wave
|
|
})
|
|
activeMorphs.push({ obj: seg, type: 'beast' })
|
|
}
|
|
|
|
// Claws on hands
|
|
for (let c = 0; c < 2; c++) {
|
|
const cDir = c === 0 ? -1 : 1
|
|
for (let cl = 0; cl < 3; cl++) {
|
|
const claw = k.add([
|
|
k.rect(2, 6), k.pos(fighter.pos.x + cDir * 22, fighter.pos.y - 18 + cl * 3),
|
|
k.color(safeColor(k,beastColors[2])),
|
|
k.opacity(0.8), k.z(fighter.z + 1), k.anchor('center'),
|
|
k.rotate(cDir * (30 + cl * 10)),
|
|
])
|
|
claw.onUpdate(() => {
|
|
claw.pos.x = fighter.pos.x + cDir * 22
|
|
claw.pos.y = fighter.pos.y - 18 + cl * 3
|
|
})
|
|
activeMorphs.push({ obj: claw, type: 'beast' })
|
|
}
|
|
}
|
|
|
|
// Wild eye glow
|
|
const eyeGlow = k.add([
|
|
k.circle(4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 36),
|
|
k.color(safeColor(k,'#ff0000')), k.opacity(0.6), k.z(fighter.z + 2), k.anchor('center'),
|
|
])
|
|
eyeGlow.onUpdate(() => {
|
|
eyeGlow.pos.x = fighter.pos.x + 5 * dir
|
|
eyeGlow.pos.y = fighter.pos.y - 36
|
|
eyeGlow.opacity = 0.4 + Math.sin(k.time() * 15) * 0.4
|
|
})
|
|
activeMorphs.push({ obj: eyeGlow, type: 'beast' })
|
|
|
|
// Size increase + color tint
|
|
fighter.color = safeColor(k,beastColors[0])
|
|
fighter.opacity = 0.9
|
|
}
|
|
|
|
const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph]
|
|
const morphNames = ['ELEMENTAL FORM', 'MECH ARMOR', 'BEAST MODE']
|
|
|
|
// === THE CREATOR: OMNI-MORPH ===
|
|
// Instead of 3 morph types, the creator morphs into a random archetype each time
|
|
const OMNI_MORPH_ARCHETYPES = [
|
|
'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'cactus', 'pizza',
|
|
'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', 'ghost', 'alien', 'dinosaur',
|
|
'pirate', 'ninja', 'cowboy', 'wizard', 'bee', 'frog', 'snail',
|
|
'robot', 'android', 'toaster', 'mech', 'minotaur', 'unicorn', 'phoenix', 'dragon',
|
|
'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem', 'vampire', 'werewolf', 'zombie',
|
|
'witch', 'demon', 'chef', 'firefighter', 'astronaut', 'clown', 'detective',
|
|
'lumberjack', 'scientist', 'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight',
|
|
'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon', 'snake',
|
|
'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda', 'hamster',
|
|
'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'cloud_man', 'rock_man',
|
|
'balloon_man', 'trash_can', 'rubber_duck', 'snowman', 'scarecrow', 'jack_o_lantern',
|
|
'garden_gnome', 'lamp_post', 'broom_man',
|
|
]
|
|
|
|
let omniMorphCounter = 0
|
|
|
|
async function applyCreatorOmniMorph(fighter: any, side: 'a' | 'b'): Promise<string> {
|
|
const pick = OMNI_MORPH_ARCHETYPES[Math.floor(Math.random() * OMNI_MORPH_ARCHETYPES.length)]
|
|
|
|
// Generate and load a sprite sheet for the picked archetype
|
|
const bot = side === 'a' ? botA : botB
|
|
const colors = side === 'a' ? colorsA : colorsB
|
|
const morphKey = `omniMorph_${side}_${omniMorphCounter++}`
|
|
try {
|
|
const morphSheet = generateSpriteSheet(bot.seed, bot.tier, colors.primary, colors.secondary, pick)
|
|
await loadSpriteWithTimeout(morphKey, morphSheet, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
|
// Swap the fighter's sprite to the morphed archetype
|
|
fighter.use(k.sprite(morphKey, { anim: fighter.curAnim() || 'idle' }))
|
|
} catch (err) {
|
|
console.warn('[FightScene] omni-morph sprite failed:', err)
|
|
}
|
|
|
|
// Golden aura unique to creator morph
|
|
const aura = k.add([
|
|
k.circle(55),
|
|
k.pos(fighter.pos.x, fighter.pos.y - 20),
|
|
k.color(safeColor(k, '#c8a000')),
|
|
k.opacity(0.2),
|
|
k.z(fighter.z - 1),
|
|
k.anchor('center'),
|
|
k.scale(1),
|
|
])
|
|
aura.onUpdate(() => {
|
|
aura.pos.x = fighter.pos.x
|
|
aura.pos.y = fighter.pos.y - 20
|
|
aura.opacity = 0.12 + Math.sin(k.time() * 6) * 0.1
|
|
const s = 1 + Math.sin(k.time() * 4) * 0.2
|
|
aura.scale.x = s; aura.scale.y = s
|
|
})
|
|
activeMorphs.push({ obj: aura, type: 'omni' })
|
|
|
|
// Orbiting golden ₿ symbols (8 in dual rings, varied sizes)
|
|
for (let i = 0; i < 8; i++) {
|
|
const ring = i < 5 ? 0 : 1 // inner ring (5) + outer ring (3)
|
|
const ringCount = ring === 0 ? 5 : 3
|
|
const ringIdx = ring === 0 ? i : i - 5
|
|
const sz = ring === 0 ? 10 + Math.random() * 4 : 14 + Math.random() * 4
|
|
const rad = ring === 0 ? 40 : 60
|
|
const spd = ring === 0 ? 3 : -2 // counter-rotate outer ring
|
|
const btc = k.add([
|
|
k.text('₿', { size: sz }),
|
|
k.pos(fighter.pos.x, fighter.pos.y - 20),
|
|
k.color(safeColor(k, i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')),
|
|
k.opacity(0.8),
|
|
k.z(fighter.z + 2),
|
|
k.anchor('center'),
|
|
k.rotate(0),
|
|
])
|
|
const baseAngle = (ringIdx / ringCount) * Math.PI * 2
|
|
btc.onUpdate(() => {
|
|
const a = baseAngle + k.time() * spd
|
|
btc.pos.x = fighter.pos.x + Math.cos(a) * rad
|
|
btc.pos.y = fighter.pos.y - 20 + Math.sin(a) * rad * 0.5
|
|
btc.opacity = 0.7 + Math.sin(k.time() * 6 + i * 1.3) * 0.3
|
|
btc.angle = Math.sin(k.time() * 4 + i) * 20
|
|
})
|
|
activeMorphs.push({ obj: btc, type: 'omni' })
|
|
}
|
|
|
|
// Apply archetype-specific color tint based on pick category
|
|
const tints: Record<string, string> = {
|
|
fire: '#ff4400', ice: '#44ccff', nature: '#44cc44', dark: '#8844cc',
|
|
metal: '#aabbcc', electric: '#ffff00', beast: '#cc6622', cosmic: '#cc44ff',
|
|
}
|
|
const category =
|
|
['phoenix', 'dragon', 'demon'].includes(pick) ? 'fire' :
|
|
['penguin', 'snowman', 'yeti'].includes(pick) ? 'ice' :
|
|
['cactus', 'mushroom', 'frog', 'snail', 'turtle'].includes(pick) ? 'nature' :
|
|
['skeleton', 'ghost', 'vampire', 'zombie', 'witch'].includes(pick) ? 'dark' :
|
|
['robot', 'android', 'mech', 'toaster', 'knight'].includes(pick) ? 'metal' :
|
|
['alien', 'unicorn', 'mermaid'].includes(pick) ? 'cosmic' :
|
|
['werewolf', 'minotaur', 'lion', 'shark', 'crocodile'].includes(pick) ? 'beast' :
|
|
'electric'
|
|
fighter.color = safeColor(k, tints[category] || '#ffd700')
|
|
fighter.opacity = 0.9
|
|
|
|
return pick
|
|
}
|
|
|
|
// Get morph order for a bot (deterministic by seed)
|
|
function getMorphOrder(seed: string): number[] {
|
|
let h = 0
|
|
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
|
|
const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]
|
|
return orders[Math.abs(h) % 6]
|
|
}
|
|
|
|
async function playMorph(fighter: any, side: 'a' | 'b', morphIndex: number) {
|
|
const savedScaleX = fighter.scale.x
|
|
const savedScaleY = fighter.scale.y
|
|
const isHumanFighter = side === 'a' ? isHumanA : isHumanB
|
|
const morphSpriteKey = side === 'a' ? 'botA_morph' : 'botB_morph'
|
|
const normalSpriteKey = side === 'a' ? 'botA' : 'botB'
|
|
|
|
// Flash + announcement
|
|
screenFlash('#ffffff', 0.15)
|
|
sfxZoomWhoosh()
|
|
sfxSpecial()
|
|
const name = side === 'a' ? botA.name : botB.name
|
|
announceHype(`${name} — ${morphNames[morphIndex]}!`)
|
|
|
|
// Scale up dramatically
|
|
const growFactor = 1.3
|
|
await k.tween(0, 1, 0.3, (t) => {
|
|
fighter.scale.x = savedScaleX * (1 + t * (growFactor - 1))
|
|
fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1))
|
|
}, k.easings.easeOutBack)
|
|
|
|
// Human fighters morph into bots (opposite direction!)
|
|
if (isHumanFighter) {
|
|
fighter.use(k.sprite(morphSpriteKey, { anim: 'idle' }))
|
|
}
|
|
|
|
// Apply the morph visuals
|
|
morphAppliers[morphIndex](fighter, side)
|
|
|
|
// Shockwave + shake
|
|
spawnShockwave(fighter.pos.x, fighter.pos.y - 20, '#ffffff')
|
|
k.shake(8)
|
|
glitchRGB(0.2)
|
|
|
|
return {
|
|
revert: async () => {
|
|
destroyMorphOverlays()
|
|
screenFlash(theme.accent, 0.1)
|
|
fighter.color = safeColor(k,'#ffffff')
|
|
fighter.opacity = 1
|
|
// Revert human fighters back to their human sprite
|
|
if (isHumanFighter) {
|
|
fighter.use(k.sprite(normalSpriteKey, { anim: 'idle' }))
|
|
}
|
|
await k.tween(0, 1, 0.2, (t) => {
|
|
fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor)
|
|
fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor)
|
|
}, k.easings.easeInQuad)
|
|
},
|
|
}
|
|
}
|
|
|
|
// === EMOTION & SPORTSMANSHIP SYSTEM ===
|
|
|
|
const heartfeltLines = [
|
|
'That bot fights like it\'s got student loans to pay!',
|
|
'I\'m not crying, you\'re crying!',
|
|
'Someone get this bot a movie deal!',
|
|
'This is better than anything on Netflix right now!',
|
|
'That bot has more heart than most politicians!',
|
|
'I haven\'t been this moved since my last Windows update!',
|
|
'The crowd is ugly crying and I respect that!',
|
|
'This bot has main character energy!',
|
|
'We\'re witnessing greatness and I don\'t say that lightly!',
|
|
'Even the judge is getting emotional!',
|
|
'Someone call their developer, they\'d be so proud!',
|
|
'This is the kind of fight you tell your grandkids about!',
|
|
'That\'s not a bot, that\'s a WARRIOR!',
|
|
'More drama than a Congressional hearing!',
|
|
'They left it all in the ring! And by all, I mean EVERYTHING!',
|
|
'The crowd is standing! The ones who can still stand, anyway!',
|
|
'Somebody film this before it gets taken down!',
|
|
'This fight has more plot twists than a Netflix original!',
|
|
'That bot fights like rent is due tomorrow!',
|
|
'My heart! My poor little heart!',
|
|
]
|
|
|
|
const crowdSympathyLines = [
|
|
'Pour one out for the fallen!',
|
|
'That\'s rough, buddy...',
|
|
'Somebody hug that bot!',
|
|
'They tried their best, which is... concerning!',
|
|
'At least they have a great personality!',
|
|
'Their developer still loves them! Probably!',
|
|
'It\'s a learning experience! A very painful one!',
|
|
'Better luck next patch!',
|
|
'Participation trophy incoming!',
|
|
'The exit is to your left!',
|
|
'Have you tried turning it off and on again?',
|
|
'Even their antivirus felt that!',
|
|
'They\'ll be in therapy for epochs after this!',
|
|
'At least they looked good losing!',
|
|
'That bot just became a cautionary tale!',
|
|
'Someone start a GoFundMe for their repairs!',
|
|
'Their training data did NOT prepare them for this!',
|
|
'That bot needs a hug and a firmware update!',
|
|
]
|
|
|
|
const respectLines = [
|
|
'Good fight.', 'Respect.', 'GG no re.',
|
|
'Same time next week?',
|
|
'You\'re built different. Not better, but different.',
|
|
'We should start a podcast.',
|
|
'That was actually fun. Don\'t tell anyone.',
|
|
'Your developer should be proud. Probably.',
|
|
'Ten out of ten, would fight again.',
|
|
'We\'re not so different, you and I.',
|
|
'I\'d swipe right.', 'You single?',
|
|
'Tell your GPU I said hi.',
|
|
'No hard feelings. Just hard hits.',
|
|
'You fight like someone who reads documentation.',
|
|
]
|
|
|
|
// Sanitize text for Kaplay (treats [ ] as styled text tags)
|
|
function safeText(t: string): string {
|
|
return t
|
|
.replace(/[\[\]{}<>`\\|~^]/g, '') // Kaplay styled-text tags and problematic chars
|
|
.replace(/[\x00-\x1f\x7f]/g, ' ') // Control characters → space
|
|
.replace(/[^\x20-\x7e]/g, '') // Strip non-ASCII (emoji, unicode) — Kaplay can't render them
|
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
.trim()
|
|
}
|
|
|
|
// Speech bubble above a fighter — colourful with tail
|
|
// Active speech bubbles — destroy previous before showing new
|
|
let activeBubbleA: any[] = []
|
|
let activeBubbleB: any[] = []
|
|
|
|
function destroyBubble(els: any[]) {
|
|
els.forEach(el => { if (el.exists()) el.destroy() })
|
|
els.length = 0
|
|
}
|
|
|
|
function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = 3) {
|
|
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
if (!fighter || !text) return
|
|
|
|
// Kill previous bubble on this side
|
|
if (side === 'a') destroyBubble(activeBubbleA)
|
|
else destroyBubble(activeBubbleB)
|
|
|
|
const allEls: any[] = []
|
|
|
|
// Sanitize and truncate
|
|
const clean = safeText(text)
|
|
if (!clean) return
|
|
const maxLen = 80
|
|
const display = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '...' : clean
|
|
const lines = wrapBubbleText(display, 18)
|
|
const fontSize = 12
|
|
const lineH = fontSize + 4
|
|
const padX = 10, padY = 8
|
|
const textW = Math.max(60, Math.min(lines.reduce((m, l) => Math.max(m, l.length * (fontSize * 0.6)), 0) + 8, W * 0.38))
|
|
const bubbleW = textW + padX * 2
|
|
const bubbleH = lines.length * lineH + padY * 2
|
|
const tailSize = 10
|
|
|
|
// Position — push bubble to the outside of the bot so it doesn't overlap the sprite
|
|
const offsetX = side === 'a' ? -bubbleW * 0.7 : bubbleW * 0.7
|
|
const bx = Math.max(bubbleW / 2 + 4, Math.min(W - bubbleW / 2 - 4, fighter.pos.x + offsetX))
|
|
const by = Math.max(8, fighter.pos.y - 75 - bubbleH)
|
|
|
|
const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
|
|
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
|
|
|
|
// Single border rect (no glow/accent layers — keeps rendering clean)
|
|
const border = k.add([
|
|
k.rect(bubbleW + 4, bubbleH + 4),
|
|
k.pos(bx - bubbleW / 2 - 2, by - 2),
|
|
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
|
|
])
|
|
allEls.push(border)
|
|
|
|
// Bubble body
|
|
const bubble = k.add([
|
|
k.rect(bubbleW, bubbleH),
|
|
k.pos(bx - bubbleW / 2, by),
|
|
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
|
|
])
|
|
allEls.push(bubble)
|
|
|
|
// Tail — pixel arrow pointing down to fighter
|
|
const tailX = bx + (side === 'a' ? bubbleW * 0.15 : -bubbleW * 0.15)
|
|
const tailBaseY = by + bubbleH
|
|
for (let row = 0; row < tailSize; row++) {
|
|
const tw = tailSize - row
|
|
// Border pixel row
|
|
const tailBorder = k.add([
|
|
k.rect(tw + 2, 1),
|
|
k.pos(tailX - (tw + 2) / 2, tailBaseY + row),
|
|
k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54),
|
|
])
|
|
allEls.push(tailBorder)
|
|
// Inner pixel row
|
|
if (tw > 2) {
|
|
const tailInner = k.add([
|
|
k.rect(tw - 1, 1),
|
|
k.pos(tailX - (tw - 1) / 2, tailBaseY + row),
|
|
k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55),
|
|
])
|
|
allEls.push(tailInner)
|
|
}
|
|
}
|
|
|
|
// Render text to an offscreen canvas, then display as a sprite
|
|
// This bypasses kaplay's text rendering which has color issues
|
|
const textCanvas = document.createElement('canvas')
|
|
textCanvas.width = bubbleW
|
|
textCanvas.height = bubbleH
|
|
const tc = textCanvas.getContext('2d')!
|
|
tc.fillStyle = '#ffffff'
|
|
tc.font = `${fontSize}px monospace`
|
|
tc.textBaseline = 'top'
|
|
for (let i = 0; i < lines.length; i++) {
|
|
tc.fillText(lines[i], padX, padY + i * lineH + 2)
|
|
}
|
|
const spriteKey = `bubble_${side}_${Date.now()}`
|
|
k.loadSprite(spriteKey, textCanvas.toDataURL()).then(() => {
|
|
const tEl = k.add([
|
|
k.sprite(spriteKey),
|
|
k.pos(bx - bubbleW / 2, by),
|
|
k.opacity(1), k.z(57),
|
|
])
|
|
allEls.push(tEl)
|
|
})
|
|
|
|
// Border glow pulse
|
|
border.onUpdate(() => {
|
|
border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15
|
|
})
|
|
|
|
// Pop-in animation
|
|
const anchorX = bx, anchorY = by + bubbleH
|
|
allEls.forEach(el => {
|
|
const origX = el.pos.x, origY = el.pos.y
|
|
const origOpacity = el.opacity
|
|
el.pos.x = anchorX + (origX - anchorX) * 0.1
|
|
el.pos.y = anchorY + (origY - anchorY) * 0.1
|
|
el.opacity = 0
|
|
k.tween(0, 1, 0.25, (t) => {
|
|
el.pos.x = anchorX + (origX - anchorX) * t
|
|
el.pos.y = anchorY + (origY - anchorY) * t
|
|
el.opacity = origOpacity * t
|
|
}, k.easings.easeOutBack)
|
|
})
|
|
|
|
// Store reference
|
|
if (side === 'a') activeBubbleA = allEls
|
|
else activeBubbleB = allEls
|
|
|
|
// Fade out after duration
|
|
setTimeout(() => {
|
|
allEls.forEach(el => {
|
|
if (!el.exists()) return
|
|
k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => {
|
|
if (el.exists()) el.destroy()
|
|
})
|
|
})
|
|
}, duration * 1000)
|
|
}
|
|
|
|
function wrapBubbleText(text: string, maxChars: number): string[] {
|
|
const words = text.split(' ')
|
|
const lines: string[] = []
|
|
let line = ''
|
|
for (const word of words) {
|
|
if (line.length + word.length + 1 > maxChars && line.length > 0) {
|
|
lines.push(line)
|
|
line = word
|
|
} else {
|
|
line = line ? line + ' ' + word : word
|
|
}
|
|
}
|
|
if (line) lines.push(line)
|
|
// Max 5 lines, last line truncated
|
|
if (lines.length > 5) {
|
|
const truncated = [...lines.slice(0, 4), lines.slice(4).join(' ').slice(0, maxChars - 3) + '...']
|
|
return truncated
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// Talking mouth animation — a small rectangle that opens/closes near the fighter's face
|
|
const talkingAnims: Record<string, { objs: any[]; timer: ReturnType<typeof setInterval> }> = {}
|
|
|
|
function startTalking(side: 'a' | 'b') {
|
|
stopTalking(side) // cleanup any existing
|
|
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
if (!fighter?.exists()) return
|
|
|
|
const mouthColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
|
|
// Mouth position: slightly below center of the sprite
|
|
const mouthOffsetY = -28
|
|
const mouthW = 8
|
|
let open = false
|
|
|
|
const mouth = k.add([
|
|
k.rect(mouthW, 2),
|
|
k.pos(fighter.pos.x, fighter.pos.y + mouthOffsetY),
|
|
k.color(safeColor(k, mouthColor)),
|
|
k.opacity(0.9),
|
|
k.z(12),
|
|
k.anchor('center'),
|
|
])
|
|
|
|
// Toggle open/close every 120ms for a fast chatter effect
|
|
const timer = setInterval(() => {
|
|
if (!mouth.exists() || !fighter.exists()) { stopTalking(side); return }
|
|
open = !open
|
|
mouth.pos.x = fighter.pos.x
|
|
mouth.pos.y = fighter.pos.y + mouthOffsetY
|
|
mouth.height = open ? 5 : 2
|
|
}, 120)
|
|
|
|
talkingAnims[side] = { objs: [mouth], timer }
|
|
}
|
|
|
|
function stopTalking(side: 'a' | 'b') {
|
|
const anim = talkingAnims[side]
|
|
if (!anim) return
|
|
clearInterval(anim.timer)
|
|
anim.objs.forEach(o => { if (o.exists()) o.destroy() })
|
|
delete talkingAnims[side]
|
|
}
|
|
|
|
// Spawn floating text above a position
|
|
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
|
|
const label = k.add([
|
|
k.text(safeText(text), { size: 10 }),
|
|
k.pos(x, y - 10),
|
|
k.color(safeColor(k,color)),
|
|
k.opacity(1),
|
|
k.z(60),
|
|
k.anchor('center'),
|
|
])
|
|
k.tween(label.pos.y, y - 50, duration, (v) => { label.pos.y = v }, k.easings.easeOutQuad)
|
|
k.tween(1, 0, duration, (v) => { label.opacity = v }, k.easings.easeInQuad).then(() => {
|
|
if (label.exists()) label.destroy()
|
|
})
|
|
}
|
|
|
|
// Spawn a heart particle
|
|
function spawnHeart(x: number, y: number) {
|
|
const colors = ['#ff4466', '#ff6688', '#ff2244', '#ff88aa', '#cc2244']
|
|
const heart = k.add([
|
|
k.text('\u2665', { size: 12 + Math.random() * 8 }),
|
|
k.pos(x + (Math.random() - 0.5) * 40, y),
|
|
k.color(safeColor(k,colors[Math.floor(Math.random() * colors.length)])),
|
|
k.opacity(0.8),
|
|
k.z(58),
|
|
k.anchor('center'),
|
|
])
|
|
const driftX = (Math.random() - 0.5) * 30
|
|
k.tween(heart.pos.y, y - 60 - Math.random() * 40, 1.5, (v) => {
|
|
heart.pos.y = v
|
|
heart.pos.x += driftX * 0.01
|
|
}, k.easings.easeOutQuad)
|
|
k.tween(0.8, 0, 1.5, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
|
|
}
|
|
|
|
// Crowd signs: pop up from bottom with text, bob, then fade out
|
|
function spawnCrowdSigns(count: number, color: string, text?: string) {
|
|
const labels = text ? [text] : ['GO!', 'WOW', 'KO!', 'LOL', 'GG', 'OMG']
|
|
for (let i = 0; i < count; i++) {
|
|
const x = W * 0.15 + Math.random() * W * 0.7
|
|
const startY = H + 20
|
|
const endY = GROUND_Y + 10 + Math.random() * 20
|
|
const label = labels[Math.floor(Math.random() * labels.length)]
|
|
// Sign stick
|
|
const stick = k.add([
|
|
k.rect(3, 25),
|
|
k.pos(x, startY),
|
|
k.color(safeColor(k, '#886644')),
|
|
k.opacity(0.8),
|
|
k.z(8),
|
|
k.anchor('bot'),
|
|
])
|
|
// Sign face
|
|
const sign = k.add([
|
|
k.rect(Math.max(30, label.length * 10), 18),
|
|
k.pos(x, startY - 25),
|
|
k.color(safeColor(k, color)),
|
|
k.opacity(0.85),
|
|
k.z(8),
|
|
k.anchor('center'),
|
|
])
|
|
// Sign text
|
|
const txt = k.add([
|
|
k.text(label, { size: 10 }),
|
|
k.pos(x, startY - 25),
|
|
k.color(safeColor(k, '#ffffff')),
|
|
k.opacity(0.9),
|
|
k.z(9),
|
|
k.anchor('center'),
|
|
])
|
|
const delay = i * 0.08
|
|
// Rise up
|
|
k.wait(delay, () => {
|
|
k.tween(startY, endY, 0.3, (v) => { stick.pos.y = v; sign.pos.y = v - 25; txt.pos.y = v - 25 }, k.easings.easeOutBack)
|
|
})
|
|
// Bob + fade out after 1.5s
|
|
k.wait(delay + 1.5, () => {
|
|
k.tween(0.85, 0, 0.5, (v) => { stick.opacity = v; sign.opacity = v; txt.opacity = v }).then(() => {
|
|
if (stick.exists()) stick.destroy()
|
|
if (sign.exists()) sign.destroy()
|
|
if (txt.exists()) txt.destroy()
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// === HUMAN OWNER APPEARANCES ===
|
|
// The silly human behind each bot occasionally shows up
|
|
|
|
/** Spawn a human as a "coach" standing behind the fighter, cheering/panicking */
|
|
async function spawnHumanCoach(side: 'a' | 'b', mood: 'cheer' | 'panic' | 'coach') {
|
|
const spriteId = side === 'a' ? 'humanA' : 'humanB'
|
|
const homeX = side === 'a' ? HOME_A - 60 : HOME_B + 60
|
|
const tag = `human_${side}`
|
|
// Don't spawn if already visible
|
|
if (k.get(tag).length > 0) return
|
|
|
|
const human = k.add([
|
|
k.sprite(spriteId, { anim: mood }),
|
|
k.pos(homeX, GROUND_Y - 4),
|
|
k.anchor('bot'),
|
|
k.scale(side === 'a' ? 1.2 : -1.2, 1.2),
|
|
k.z(5),
|
|
k.opacity(0),
|
|
tag,
|
|
])
|
|
|
|
// Fade in
|
|
await k.tween(0, 0.85, 0.3, (v) => { human.opacity = v }, k.easings.easeOutQuad)
|
|
await k.wait(2.0)
|
|
// Fade out
|
|
await k.tween(0.85, 0, 0.5, (v) => { human.opacity = v }, k.easings.easeInQuad)
|
|
if (human.exists()) human.destroy()
|
|
}
|
|
|
|
/** Human runs across the screen (comedic moment) */
|
|
async function humanRunAcross(side: 'a' | 'b') {
|
|
const spriteId = side === 'a' ? 'humanA' : 'humanB'
|
|
const fromLeft = side === 'a'
|
|
const startX = fromLeft ? -40 : W + 40
|
|
const endX = fromLeft ? W + 40 : -40
|
|
|
|
const human = k.add([
|
|
k.sprite(spriteId, { anim: 'run' }),
|
|
k.pos(startX, GROUND_Y - 4),
|
|
k.anchor('bot'),
|
|
k.scale(fromLeft ? 1.3 : -1.3, 1.3),
|
|
k.z(8),
|
|
k.opacity(0.9),
|
|
])
|
|
|
|
// Run across screen
|
|
await k.tween(startX, endX, 1.5, (v) => { human.pos.x = v }, k.easings.linear)
|
|
if (human.exists()) human.destroy()
|
|
}
|
|
|
|
/** Human briefly replaces the bot sprite (jumps in to fight, fails hilariously) */
|
|
async function humanJumpsIn(side: 'a' | 'b') {
|
|
const fighterTag = side === 'a' ? 'fighterA' : 'fighterB'
|
|
const spriteId = side === 'a' ? 'humanA' : 'humanB'
|
|
const fighter = k.get(fighterTag)[0] as Fighter
|
|
if (!fighter) return
|
|
|
|
// Spawn human at fighter position
|
|
const human = k.add([
|
|
k.sprite(spriteId, { anim: 'panic' }),
|
|
k.pos(fighter.pos.x, fighter.pos.y),
|
|
k.anchor('bot'),
|
|
k.scale(fighter.scale.x / 3, fighter.scale.y / 3),
|
|
k.z(fighter.z + 1),
|
|
k.opacity(0),
|
|
])
|
|
|
|
// Hide bot, show human
|
|
const origOpacity = fighter.opacity
|
|
fighter.opacity = 0
|
|
await k.tween(0, 1, 0.15, (v) => { human.opacity = v })
|
|
|
|
// Human flails around for a moment
|
|
human.play('panic')
|
|
await k.wait(0.4)
|
|
human.play('cheer')
|
|
sfxRandomSilly()
|
|
await k.wait(0.3)
|
|
human.play('panic')
|
|
await k.wait(0.3)
|
|
|
|
// Run away scared
|
|
const escapeX = side === 'a' ? -80 : W + 80
|
|
human.play('run')
|
|
await k.tween(human.pos.x, escapeX, 0.5, (v) => { human.pos.x = v }, k.easings.easeInQuad)
|
|
|
|
// Restore bot
|
|
fighter.opacity = origOpacity
|
|
if (human.exists()) human.destroy()
|
|
}
|
|
|
|
/** Random chance to show a human moment — called between rounds */
|
|
async function maybeShowHuman() {
|
|
const roll = Math.random()
|
|
if (roll > 0.15) return // 15% chance per round
|
|
|
|
const side: 'a' | 'b' = Math.random() < 0.5 ? 'a' : 'b'
|
|
const action = Math.random()
|
|
|
|
if (action < 0.4) {
|
|
// Coach appearance (most common)
|
|
const mood = Math.random() < 0.5 ? 'cheer' : 'panic'
|
|
await spawnHumanCoach(side, mood)
|
|
} else if (action < 0.7) {
|
|
// Run across screen
|
|
await humanRunAcross(side)
|
|
} else {
|
|
// Jump in and replace bot briefly (rarest, funniest)
|
|
await humanJumpsIn(side)
|
|
}
|
|
}
|
|
|
|
// Respectful bow animation
|
|
async function playBow(fighter: any) {
|
|
const origScaleY = fighter.scale.y
|
|
await k.tween(fighter.scale.y, origScaleY * 0.85, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.3)
|
|
await k.tween(fighter.scale.y, origScaleY, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad)
|
|
}
|
|
|
|
// Fist bump between two fighters
|
|
async function playFistBump(fighterA: any, fighterB: any) {
|
|
const midX = (fighterA.pos.x + fighterB.pos.x) / 2
|
|
const origAX = fighterA.pos.x
|
|
const origBX = fighterB.pos.x
|
|
await Promise.all([
|
|
k.tween(fighterA.pos.x, midX - 15, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(fighterB.pos.x, midX + 15, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
sfxBlock()
|
|
spawnSparks(midX, fighterA.pos.y - 25, 6, '#ffe14d')
|
|
spawnEmoteText(midX, fighterA.pos.y - 40, 'GG!', '#ffe14d')
|
|
k.shake(2)
|
|
await k.wait(0.5)
|
|
await Promise.all([
|
|
k.tween(fighterA.pos.x, origAX, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(fighterB.pos.x, origBX, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// Winner helps loser back up
|
|
async function playHelpUp(winner: any, loser: any, winningSide: 'a' | 'b') {
|
|
const dir = winningSide === 'a' ? 1 : -1
|
|
const origWX = winner.pos.x
|
|
await k.tween(winner.pos.x, loser.pos.x - dir * 30, 0.4, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
|
winner.play('idle')
|
|
await k.wait(0.2)
|
|
loser.play('idle')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 40, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
|
|
for (let i = 0; i < 3; i++) spawnHeart((winner.pos.x + loser.pos.x) / 2, winner.pos.y - 30)
|
|
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
|
await k.wait(0.6)
|
|
await k.tween(winner.pos.x, origWX, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
|
}
|
|
|
|
// === REFEREE LOBSTER FUNNY MOMENTS ===
|
|
|
|
async function judgeDoSomethingFunny() {
|
|
const judge = k.get('judge')[0]
|
|
if (!judge) return
|
|
|
|
const judgeOrigY = judge.pos.y
|
|
const funnyAction = Math.floor(Math.random() * 8)
|
|
|
|
if (funnyAction === 0) {
|
|
// Falls asleep, snaps awake
|
|
for (let z = 0; z < 3; z++) {
|
|
setTimeout(() => {
|
|
spawnEmoteText(judge.pos.x + 15, judge.pos.y - 20, 'Z', '#8888ff', 1.0)
|
|
}, z * 400)
|
|
}
|
|
await k.wait(1.2)
|
|
judge.play('shocked')
|
|
k.shake(2)
|
|
sfxBoing()
|
|
spawnEmoteText(judge.pos.x, judge.pos.y - 30, '!?', '#ff4444')
|
|
await k.tween(judge.pos.y, judgeOrigY - 20, 0.1, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(judge.pos.y, judgeOrigY, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
|
|
await k.wait(0.3)
|
|
judge.play('idle')
|
|
} else if (funnyAction === 1) {
|
|
// Little dance on the chair
|
|
for (let d = 0; d < 4; d++) {
|
|
await k.tween(judge.pos.y, judgeOrigY - 8, 0.08, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
|
|
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')
|
|
}
|
|
sfxRandomComedy()
|
|
judge.play('idle')
|
|
} else if (funnyAction === 2) {
|
|
// Holds up a 10 score card
|
|
judge.play('shocked')
|
|
const card = k.add([
|
|
k.rect(20, 14), k.pos(judge.pos.x + 20, judge.pos.y - 25),
|
|
k.color(safeColor(k,'#ffffff')), k.opacity(0.9), k.z(15), k.anchor('center'),
|
|
])
|
|
const score = k.add([
|
|
k.text('10', { size: 10 }), k.pos(judge.pos.x + 20, judge.pos.y - 25),
|
|
k.color(safeColor(k,'#000000')), k.opacity(1), k.z(16), k.anchor('center'),
|
|
])
|
|
sfxBoing()
|
|
await k.wait(1.0)
|
|
card.destroy(); score.destroy()
|
|
judge.play('idle')
|
|
} else if (funnyAction === 3) {
|
|
// Gets scared and hides behind the chair
|
|
judge.play('shocked')
|
|
sfxDodge()
|
|
await k.tween(judge.pos.y, judgeOrigY + 20, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
|
|
judge.opacity = 0.3
|
|
await k.wait(0.8)
|
|
await k.tween(judge.pos.y, judgeOrigY - 5, 0.2, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
|
|
judge.opacity = 0.9
|
|
await k.tween(judge.pos.y, judgeOrigY, 0.1, (v) => { judge.pos.y = v }, k.easings.easeInOutQuad)
|
|
judge.play('idle')
|
|
} else if (funnyAction === 4) {
|
|
// Eats a tiny sandwich
|
|
const sandwich = k.add([
|
|
k.rect(10, 8), k.pos(judge.pos.x - 15, judge.pos.y - 15),
|
|
k.color(safeColor(k,'#ddaa44')), k.opacity(0.9), k.z(15), k.anchor('center'),
|
|
])
|
|
const lettuce = k.add([
|
|
k.rect(12, 2), k.pos(judge.pos.x - 15, judge.pos.y - 15),
|
|
k.color(safeColor(k,'#44cc22')), k.opacity(0.8), k.z(16), k.anchor('center'),
|
|
])
|
|
await k.wait(0.4)
|
|
await k.tween(sandwich.pos.x, judge.pos.x, 0.2, (v) => { sandwich.pos.x = v; lettuce.pos.x = v }, k.easings.easeInQuad)
|
|
sandwich.destroy(); lettuce.destroy()
|
|
spawnEmoteText(judge.pos.x, judge.pos.y - 30, 'nom nom', '#ffcc44')
|
|
sfxBonk()
|
|
await k.wait(0.6)
|
|
} else if (funnyAction === 5) {
|
|
// Takes a selfie
|
|
judge.play('call_right')
|
|
const phone = k.add([
|
|
k.rect(6, 10), k.pos(judge.pos.x + 20, judge.pos.y - 20),
|
|
k.color(safeColor(k,'#333344')), k.opacity(0.9), k.z(15), k.anchor('center'),
|
|
])
|
|
await k.wait(0.3)
|
|
screenFlash('#ffffff', 0.05)
|
|
sfxZap()
|
|
spawnEmoteText(judge.pos.x, judge.pos.y - 35, '#selfie', '#ff88cc')
|
|
await k.wait(0.5)
|
|
phone.destroy()
|
|
judge.play('idle')
|
|
} else if (funnyAction === 6) {
|
|
// Waves a tiny flag
|
|
const flagColors = ['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00', '#ff6600', '#b83dff']
|
|
const flagColor = flagColors[Math.floor(Math.random() * flagColors.length)]
|
|
const flag = k.add([
|
|
k.rect(14, 9), k.pos(judge.pos.x + 18, judge.pos.y - 30),
|
|
k.color(safeColor(k,flagColor)), k.opacity(0.85), k.z(15), k.anchor('center'),
|
|
])
|
|
const pole = k.add([
|
|
k.rect(2, 18), k.pos(judge.pos.x + 11, judge.pos.y - 22),
|
|
k.color(safeColor(k,'#aa8855')), k.opacity(0.8), k.z(14), k.anchor('center'),
|
|
])
|
|
flag.onUpdate(() => {
|
|
flag.pos.y = judge.pos.y - 30 + Math.sin(k.time() * 10) * 3
|
|
})
|
|
sfxRandomComedy()
|
|
await k.wait(1.5)
|
|
flag.destroy(); pole.destroy()
|
|
} else {
|
|
// Falls off the chair, climbs back up
|
|
judge.play('shocked')
|
|
sfxBoing()
|
|
await k.tween(judge.pos.y, GROUND_Y - 6, 0.3, (v) => { judge.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk()
|
|
k.shake(4)
|
|
spawnEmoteText(judge.pos.x, GROUND_Y - 20, 'oof!', '#ff6644')
|
|
await k.wait(0.5)
|
|
await k.tween(judge.pos.y, judgeOrigY, 0.5, (v) => { judge.pos.y = v }, k.easings.easeOutQuad)
|
|
judge.play('idle')
|
|
spawnEmoteText(judge.pos.x, judge.pos.y - 25, '*ahem*', '#aaaaaa')
|
|
await k.wait(0.3)
|
|
}
|
|
}
|
|
|
|
// === SHOWBOATING / TAUNTING SYSTEM ===
|
|
// Fighters perform these during question/answer phases instead of boring idle bobbing
|
|
const _showboatActive: Record<string, boolean> = { a: false, b: false }
|
|
|
|
type ShowboatFn = (fighter: any, homeX: number, homeY: number, sx: number, sy: number, dir: number) => Promise<void>
|
|
|
|
// dir: 1 = facing right (bot A), -1 = facing left (bot B)
|
|
const showboats: ShowboatFn[] = [
|
|
// 0: Pace back and forth — walk a wide arc restlessly
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('kick')
|
|
await k.tween(f.pos.x, hx + dir * 100, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
f.play('idle')
|
|
await k.wait(0.1)
|
|
f.play('attack')
|
|
await k.tween(f.pos.x, hx - dir * 60, 0.4, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
f.play('idle')
|
|
await k.wait(0.1)
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 1: Bouncing advance — hop toward opponent then back
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
for (let i = 0; i < 3; i++) {
|
|
const targetX = hx + dir * (40 + i * 30)
|
|
f.play('kick')
|
|
await Promise.all([
|
|
k.tween(f.pos.x, targetX, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(f.pos.y, hy - 35, 0.06, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxBoing()
|
|
}
|
|
f.play('win'); await k.wait(0.15)
|
|
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 2: Shadow box sprint — dash forward shadow boxing, dash back
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
const far = hx + dir * 120
|
|
f.play('attack'); sfxZoomWhoosh()
|
|
await k.tween(f.pos.x, far, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
for (let i = 0; i < 4; i++) {
|
|
f.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxDodge(); await k.wait(0.08)
|
|
}
|
|
f.play('special'); sfxZoomWhoosh()
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 3: Leapfrog across — multiple big jumps across the stage
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
const spots = [hx + dir * 60, hx + dir * 130, hx + dir * 60, hx]
|
|
for (const tx of spots) {
|
|
sfxBoing()
|
|
await Promise.all([
|
|
k.tween(f.pos.x, tx, 0.18, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(f.pos.y, hy - 70, 0.09, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.09, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
}
|
|
},
|
|
// 4: Restless jog — jog back and forth with small hops
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('kick')
|
|
for (let i = 0; i < 5; i++) {
|
|
const dx = (i % 2 === 0 ? 1 : -1) * (30 + Math.random() * 40) * dir
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx + dx, 0.13, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 12, 0.065, (v) => { f.pos.y = v }).then(() =>
|
|
k.tween(f.pos.y, hy, 0.065, (v) => { f.pos.y = v })
|
|
),
|
|
])
|
|
}
|
|
f.pos.x = hx
|
|
},
|
|
// 5: Charge and flex — sprint to center, flex, sprint back
|
|
async (f, hx, hy, sx, sy, _dir) => {
|
|
const center = W / 2
|
|
f.play('special'); sfxZoomWhoosh()
|
|
await k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
|
|
f.play('win')
|
|
await k.tween(1, 1.3, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
|
|
spawnEmoteText(f.pos.x, f.pos.y - 50, 'COME AT ME!', '#ff4444')
|
|
await k.wait(0.3)
|
|
await k.tween(1.3, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
|
|
sfxZoomWhoosh()
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 6: Triple backflip retreat — three backflips going backwards
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
for (let i = 0; i < 3; i++) {
|
|
sfxBoing()
|
|
const targetX = hx - dir * (i + 1) * 30
|
|
const a0 = f.angle || 0
|
|
await Promise.all([
|
|
k.tween(f.pos.x, targetX, 0.2, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 60, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
k.tween(a0, a0 - 360, 0.2, (v) => { f.angle = v }),
|
|
])
|
|
f.angle = 0
|
|
}
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 7: Kangaroo hops — big bouncing jumps across the width
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('kick')
|
|
const hops = [hx + dir * 80, hx + dir * 150, hx + dir * 80, hx]
|
|
for (const tx of hops) {
|
|
await Promise.all([
|
|
k.tween(f.pos.x, tx, 0.15, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxBonk(); k.shake(2)
|
|
}
|
|
},
|
|
// 8: Feint combo rush — charge in, throw combo, dodge back
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
sfxZoomWhoosh(); f.play('attack')
|
|
await k.tween(f.pos.x, hx + dir * 100, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
const anims = ['attack', 'kick', 'attack', 'special'] as const
|
|
for (const a of anims) { f.play(a); sfxDodge(); await k.wait(0.06) }
|
|
sfxBlock(); spawnSparks(f.pos.x + dir * 15, hy - 30, 5, '#ffcc00')
|
|
// Dodge-hop back
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(f.pos.y, hy - 40, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
},
|
|
// 9: Dizzy spin walk — spin while walking forward, stumble back
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
const a0 = f.angle || 0
|
|
f.play('hit')
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx + dir * 70, 0.4, (v) => { f.pos.x = v }),
|
|
k.tween(a0, a0 + 720, 0.4, (v) => { f.angle = v }),
|
|
])
|
|
f.angle = 0
|
|
spawnEmoteText(f.pos.x, f.pos.y - 40, '@_@', '#ffcc00')
|
|
await k.wait(0.15)
|
|
// Stumble back
|
|
f.play('idle')
|
|
for (let i = 0; i < 3; i++) {
|
|
await k.tween(f.pos.x, f.pos.x - dir * 25, 0.1, (v) => { f.pos.x = v })
|
|
f.pos.y = hy - 5; await k.wait(0.02); f.pos.y = hy
|
|
}
|
|
},
|
|
// 10: Wall-to-wall dash — full screen sprint and back
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('special'); sfxZoomWhoosh()
|
|
spawnAfterimages(f, 3)
|
|
await k.tween(f.pos.x, dir > 0 ? W - 30 : 30, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
|
|
sfxBlock(); k.shake(4)
|
|
await k.wait(0.1)
|
|
sfxZoomWhoosh(); spawnAfterimages(f, 3)
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 11: Earthquake stomp march — stomp forward and back
|
|
async (f, hx, hy, sx, sy, dir) => {
|
|
f.play('attack')
|
|
for (let i = 0; i < 4; i++) {
|
|
const step = dir * 25
|
|
await k.tween(f.pos.x, f.pos.x + step, 0.08, (v) => { f.pos.x = v })
|
|
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
|
|
await k.wait(0.05); f.scale.y = sy
|
|
}
|
|
spawnSparks(f.pos.x, hy, 6, '#ff6600')
|
|
await k.wait(0.1)
|
|
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 12: Taunt walk — strut toward opponent, gesture, moonwalk back
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('win')
|
|
await k.tween(f.pos.x, hx + dir * 90, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
f.play('special')
|
|
spawnEmoteText(f.pos.x, f.pos.y - 50, 'TOO EASY!', '#ff44ff')
|
|
await k.wait(0.3)
|
|
// Moonwalk back
|
|
f.play('idle')
|
|
await k.tween(f.pos.x, hx, 0.5, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 13: Victory lap sprint — run around in a loop
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('kick'); sfxZoomWhoosh()
|
|
await k.tween(f.pos.x, hx + dir * 140, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
|
|
// Jump over to other side
|
|
sfxBoing()
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx + dir * 140, 0.25, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 100, 0.125, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.125, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
f.play('win'); spawnSparks(f.pos.x, hy - 20, 5, theme.accent)
|
|
await k.wait(0.15)
|
|
sfxZoomWhoosh()
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 14: Nervous pacing — quick short paces with jittery hops
|
|
async (f, hx, hy) => {
|
|
for (let i = 0; i < 6; i++) {
|
|
const dx = (Math.random() - 0.5) * 80
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx + dx, 0.1, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 10 - Math.random() * 20, 0.05, (v) => { f.pos.y = v }).then(() =>
|
|
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
|
|
),
|
|
])
|
|
}
|
|
},
|
|
// 15: Ground pound jump — massive vertical jump + slam
|
|
async (f, hx, hy) => {
|
|
sfxBoing(); f.play('kick')
|
|
await k.tween(f.pos.y, hy - 150, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.1)
|
|
f.play('attack')
|
|
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(15)
|
|
spawnSparks(f.pos.x, hy, 15, '#ff6600')
|
|
spawnShockwave(f.pos.x, hy, '#ff4400')
|
|
spawnEmoteText(f.pos.x, f.pos.y - 60, 'BOOM!', '#ff2d2d')
|
|
await k.wait(0.2)
|
|
},
|
|
// 16: Zigzag sprint — fast zigzag across the arena
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('kick'); sfxZoomWhoosh()
|
|
const pts = [
|
|
{ x: hx + dir * 50, y: hy - 40 },
|
|
{ x: hx + dir * 100, y: hy },
|
|
{ x: hx + dir * 150, y: hy - 40 },
|
|
{ x: hx + dir * 100, y: hy },
|
|
{ x: hx, y: hy },
|
|
]
|
|
for (const pt of pts) {
|
|
await Promise.all([
|
|
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
|
|
])
|
|
}
|
|
},
|
|
// 17: Double jump kick — jump, kick, jump higher, kick
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('kick'); sfxBoing()
|
|
const mid = hx + dir * 60
|
|
await Promise.all([
|
|
k.tween(f.pos.x, mid, 0.15, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 50, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxKick(); spawnSparks(mid, hy - 30, 4, '#00ffcc')
|
|
f.play('special'); sfxBoing()
|
|
await Promise.all([
|
|
k.tween(f.pos.x, mid + dir * 40, 0.15, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxKick(); spawnSparks(f.pos.x, hy - 50, 6, '#ff44ff')
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 18: Cocky strut and lean — walk forward, lean back with attitude
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('win')
|
|
await k.tween(f.pos.x, hx + dir * 80, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
// Lean back
|
|
await k.tween(0, -25, 0.15, (v) => { f.angle = v }, k.easings.easeOutQuad)
|
|
await k.wait(0.25)
|
|
spawnEmoteText(f.pos.x, f.pos.y - 50, 'EZ', '#ffcc00')
|
|
await k.tween(-25, 0, 0.1, (v) => { f.angle = v })
|
|
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 19: Windmill sprint — spin while running across
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
const a0 = f.angle || 0
|
|
const anims = ['attack', 'kick', 'special'] as const
|
|
let animIdx = 0
|
|
const far = hx + dir * 130
|
|
f.play(anims[0])
|
|
// Sprint while spinning
|
|
const dur = 0.4
|
|
const startX = f.pos.x
|
|
await k.tween(0, 1, dur, (t) => {
|
|
f.pos.x = startX + (far - startX) * t
|
|
f.angle = a0 + t * 1080 // 3 full spins
|
|
if (Math.floor(t * 12) !== Math.floor((t - 0.01) * 12)) {
|
|
f.play(anims[animIdx++ % anims.length])
|
|
}
|
|
})
|
|
f.angle = 0; sfxBlock(); k.shake(3)
|
|
await k.wait(0.1)
|
|
// Sprint back
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 20: Sumo stomp walk — heavy stomps advancing and retreating
|
|
async (f, hx, hy, _sx, sy, dir) => {
|
|
f.play('attack')
|
|
for (let i = 0; i < 6; i++) {
|
|
const step = (i < 3 ? dir : -dir) * 20
|
|
await k.tween(f.pos.x, f.pos.x + step, 0.1, (v) => { f.pos.x = v })
|
|
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
|
|
await k.wait(0.06); f.scale.y = sy
|
|
spawnSparks(f.pos.x, hy, 2, '#886644')
|
|
}
|
|
},
|
|
// 21: Flex pose — walk to center, scale up, pose
|
|
async (f, hx, hy, sx, sy) => {
|
|
const center = W / 2
|
|
f.play('win')
|
|
await k.tween(f.pos.x, center, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
await k.tween(1, 1.35, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
|
|
sfxPowerUp()
|
|
spawnEmoteText(f.pos.x, f.pos.y - 60, 'COME ON!', '#ff4444')
|
|
await k.wait(0.35)
|
|
await k.tween(1.35, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 22: Jump-kick air combo — big jump with mid-air kicks
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
sfxBoing()
|
|
const apex = hy - 120
|
|
const far = hx + dir * 100
|
|
await k.tween(f.pos.y, apex, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
|
|
// Mid-air combo while moving
|
|
f.play('kick'); sfxKick()
|
|
await k.tween(f.pos.x, far, 0.1, (v) => { f.pos.x = v })
|
|
f.play('attack'); sfxPunch()
|
|
await k.tween(f.pos.x, far + dir * 30, 0.08, (v) => { f.pos.x = v })
|
|
f.play('special')
|
|
spawnSparks(f.pos.x, f.pos.y, 8, '#ff44ff')
|
|
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); k.shake(4)
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 23: Air guitar walk — bouncing left and right with music
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('special'); sfxSlideUp()
|
|
spawnEmoteText(f.pos.x, f.pos.y - 50, '~♪~', '#ff44ff')
|
|
for (let i = 0; i < 6; i++) {
|
|
const dx = (i % 2 === 0 ? dir : -dir) * (30 + i * 10)
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx + dx, 0.12, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 15, 0.06, (v) => { f.pos.y = v }).then(() =>
|
|
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v })
|
|
),
|
|
])
|
|
}
|
|
f.pos.x = hx
|
|
},
|
|
// 24: Crane kick walk — slow martial arts advancing
|
|
async (f, hx, hy, _sx, sy, dir) => {
|
|
f.play('kick')
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx + dir * 60, 0.3, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 30, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
|
|
k.tween(1, 1.15, 0.3, (v) => { f.scale.y = sy * v }, k.easings.easeOutQuad),
|
|
])
|
|
await k.wait(0.3)
|
|
f.play('special'); sfxKick()
|
|
spawnSparks(f.pos.x + dir * 20, hy - 40, 6, '#00ccff')
|
|
await k.wait(0.15)
|
|
f.scale.y = sy
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad),
|
|
])
|
|
},
|
|
// 25: Girlfriend kiss — girlfriend walks in, kiss, hearts, she storms off
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
const startGfX = hx - dir * 120
|
|
const gfColor = ['#ff69b4', '#ff1493', '#ff6eb4', '#cc44aa'][Math.floor(Math.random() * 4)]
|
|
const cs = SF
|
|
const gf = k.add([k.rect(18 * cs, 35 * cs), k.pos(startGfX, hy - 18 * cs), k.anchor('center'), k.color(safeColor(k, gfColor)), k.opacity(0.9), k.z(11)])
|
|
const gfHair = k.add([k.rect(22 * cs, 10 * cs), k.pos(startGfX, hy - 35 * cs), k.anchor('center'), k.color(safeColor(k, '#8b4513')), k.opacity(0.9), k.z(12)])
|
|
// Walk toward fighter
|
|
await Promise.all([
|
|
k.tween(gf.pos.x, hx - dir * 15, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
// Kiss!
|
|
sfxBoing()
|
|
const heart = k.add([k.text('*smooch*', { size: 10 }), k.pos(hx, hy - 55), k.anchor('center'), k.color(safeColor(k, '#ff1493')), k.opacity(1), k.z(30)])
|
|
k.tween(heart.pos.y, hy - 90, 0.8, (v) => { heart.pos.y = v })
|
|
k.tween(1, 0, 0.8, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
|
|
// Spawn floating hearts
|
|
for (let i = 0; i < 5; i++) {
|
|
const h = k.add([k.text('♥', { size: 8 + Math.random() * 6 }), k.pos(hx + (Math.random() - 0.5) * 30, hy - 30), k.color(safeColor(k, '#ff69b4')), k.opacity(0.8), k.z(25), k.anchor('center')])
|
|
k.tween(h.pos.y, hy - 70 - Math.random() * 40, 0.7, (v) => { h.pos.y = v })
|
|
k.tween(h.pos.x, h.pos.x + (Math.random() - 0.5) * 40, 0.7, (v) => { h.pos.x = v })
|
|
k.tween(0.8, 0, 0.7, (v) => { h.opacity = v }).then(() => { if (h.exists()) h.destroy() })
|
|
}
|
|
f.play('win')
|
|
await k.wait(0.5)
|
|
// She storms off
|
|
spawnEmoteText(gf.pos.x, gf.pos.y - 30, 'Good luck babe!', '#ff69b4')
|
|
await k.tween(gf.pos.x, startGfX, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInQuad)
|
|
gf.destroy(); gfHair.destroy()
|
|
},
|
|
// 26: Push-up walk — walk forward doing push-ups at intervals
|
|
async (f, hx, hy, sx, sy, dir) => {
|
|
for (let i = 0; i < 3; i++) {
|
|
await k.tween(f.pos.x, f.pos.x + dir * 35, 0.15, (v) => { f.pos.x = v })
|
|
// Push-up squash
|
|
await k.tween(1, 0.55, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeOutQuad)
|
|
await k.tween(0.55, 1, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeInQuad)
|
|
}
|
|
spawnEmoteText(f.pos.x, f.pos.y - 45, `${3 + Math.floor(Math.random() * 97)}!`, '#00ff88')
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 27: Matrix dodge walk — dodge bullets walking forward
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
for (let i = 0; i < 4; i++) {
|
|
await k.tween(f.pos.x, f.pos.x + dir * 25, 0.1, (v) => { f.pos.x = v })
|
|
// Lean dodge
|
|
const lean = (i % 2 === 0 ? -30 : 30)
|
|
await k.tween(0, lean, 0.08, (v) => { f.angle = v }, k.easings.easeOutQuad)
|
|
await k.tween(lean, 0, 0.08, (v) => { f.angle = v }, k.easings.easeInQuad)
|
|
}
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 28: Bouncy chest thump — bounce toward opponent thumping chest
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('attack')
|
|
for (let i = 0; i < 4; i++) {
|
|
await Promise.all([
|
|
k.tween(f.pos.x, f.pos.x + dir * 30, 0.1, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 25, 0.05, (v) => { f.pos.y = v }).then(() =>
|
|
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
|
|
),
|
|
])
|
|
sfxBlock(); k.shake(2)
|
|
}
|
|
spawnEmoteText(f.pos.x, f.pos.y - 50, 'LET\'S GO!', '#ff4444')
|
|
await k.wait(0.15)
|
|
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 29: Speed demon — afterimage dash back and forth
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('special')
|
|
for (let i = 0; i < 3; i++) {
|
|
sfxZoomWhoosh(); spawnAfterimages(f, 3)
|
|
await k.tween(f.pos.x, hx + dir * (80 + i * 30), 0.08, (v) => { f.pos.x = v }, k.easings.easeInQuad)
|
|
spawnAfterimages(f, 2)
|
|
await k.tween(f.pos.x, hx - dir * 20, 0.08, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
}
|
|
f.pos.x = hx
|
|
},
|
|
]
|
|
|
|
// === CREATOR-EXCLUSIVE MAGICAL SHOWBOATS ===
|
|
const creatorShowboats: ShowboatFn[] = [
|
|
// 0: Levitate across — float up and glide across the screen trailing golden ₿
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('special'); sfxPowerUp()
|
|
await k.tween(f.pos.y, hy - 100, 0.25, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
|
|
// Float across dropping ₿ rain
|
|
const startX = f.pos.x
|
|
const far = hx + dir * 180
|
|
const dur = 0.6
|
|
let lastDrop = 0
|
|
await k.tween(0, 1, dur, (t) => {
|
|
f.pos.x = startX + (far - startX) * t
|
|
f.pos.y = hy - 100 + Math.sin(t * Math.PI * 3) * 20
|
|
if (t - lastDrop > 0.08) {
|
|
lastDrop = t
|
|
const d = k.add([k.text('₿', { size: 6 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 10), k.color(safeColor(k, '#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
|
|
k.tween(d.pos.y, hy + 10, 0.5, (v) => { d.pos.y = v })
|
|
k.tween(0.7, 0, 0.5, (v) => { d.opacity = v }).then(() => { if (d.exists()) d.destroy() })
|
|
}
|
|
})
|
|
screenFlash('#ffd700', 0.06)
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
},
|
|
// 1: Golden portal teleport — vanish, portals, reappear at random spots
|
|
async (f, hx, hy) => {
|
|
sfxSpecial()
|
|
// Vanish with flash
|
|
screenFlash('#ffd700', 0.08)
|
|
f.opacity = 0
|
|
// Teleport to 3 spots with golden portals
|
|
const spots = [
|
|
{ x: W * 0.2, y: hy - 60 },
|
|
{ x: W * 0.8, y: hy - 80 },
|
|
{ x: W * 0.5, y: hy - 120 },
|
|
]
|
|
for (const spot of spots) {
|
|
// Portal flash at destination
|
|
const portal = k.add([k.circle(20), k.pos(spot.x, spot.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.6), k.z(15), k.anchor('center')])
|
|
spawnSparks(spot.x, spot.y, 6, '#ffd700')
|
|
f.pos.x = spot.x; f.pos.y = spot.y; f.opacity = 1
|
|
f.play('special')
|
|
await k.wait(0.15)
|
|
f.opacity = 0
|
|
k.tween(0.6, 0, 0.2, (v) => { portal.opacity = v }).then(() => { if (portal.exists()) portal.destroy() })
|
|
sfxZap()
|
|
}
|
|
// Return home with big flash
|
|
screenFlash('#ffd700', 0.1)
|
|
f.pos.x = hx; f.pos.y = hy; f.opacity = 1
|
|
f.play('win')
|
|
spawnShockwave(hx, hy, '#ffd700')
|
|
sfxExplosion()
|
|
await k.wait(0.2)
|
|
},
|
|
// 2: Code rain summon — float up, summon massive golden code rain
|
|
async (f, hx, hy) => {
|
|
f.play('special'); sfxPowerUp()
|
|
await k.tween(f.pos.y, hy - 80, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
|
|
// Massive code rain
|
|
const chars = '₿01∞⚡⛏§∂∆≈≠'.split('')
|
|
for (let col = 0; col < 20; col++) {
|
|
const cx = Math.random() * W
|
|
for (let row = 0; row < 3; row++) {
|
|
const ch = chars[Math.floor(Math.random() * chars.length)]
|
|
const d = k.add([
|
|
k.text(ch, { size: 6 + Math.random() * 4 }), k.pos(cx, -10 - row * 20),
|
|
k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')),
|
|
k.opacity(0.5 + Math.random() * 0.4), k.z(8), k.anchor('center'),
|
|
])
|
|
k.tween(d.pos.y, hy + 30, 0.5 + Math.random() * 0.4, (v) => { d.pos.y = v; d.opacity = Math.max(0, 1 - (v - hy + 30) / 60) })
|
|
.then(() => { if (d.exists()) d.destroy() })
|
|
}
|
|
}
|
|
await k.wait(0.6)
|
|
await k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
},
|
|
// 3: ₿ orbit explosion — fly to center, ₿ symbols orbit then explode outward
|
|
async (f, hx, hy, sx, sy) => {
|
|
sfxZoomWhoosh(); f.play('special')
|
|
const center = W / 2
|
|
await Promise.all([
|
|
k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(f.pos.y, hy - 60, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
|
|
])
|
|
// Spawn orbiting ₿
|
|
const orbs: any[] = []
|
|
for (let i = 0; i < 8; i++) {
|
|
const orb = k.add([k.text('₿', { size: 8 + Math.random() * 6 }), k.pos(center, f.pos.y), k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(15), k.anchor('center')])
|
|
orbs.push(orb)
|
|
}
|
|
// Orbit for a moment
|
|
const startTime = k.time()
|
|
const orbitCancel = k.onUpdate(() => {
|
|
const t = k.time() - startTime
|
|
for (let i = 0; i < orbs.length; i++) {
|
|
const a = t * 4 + i * (Math.PI * 2 / orbs.length)
|
|
const r = 30 + t * 15
|
|
orbs[i].pos.x = f.pos.x + Math.cos(a) * r
|
|
orbs[i].pos.y = f.pos.y + Math.sin(a) * r * 0.5
|
|
}
|
|
})
|
|
await k.wait(0.5)
|
|
orbitCancel.cancel()
|
|
// Explode outward
|
|
sfxExplosion(); screenFlash('#ffd700', 0.08); k.shake(8)
|
|
for (const orb of orbs) {
|
|
const angle = Math.random() * Math.PI * 2
|
|
k.tween(orb.pos.x, orb.pos.x + Math.cos(angle) * 200, 0.3, (v) => { orb.pos.x = v })
|
|
k.tween(orb.pos.y, orb.pos.y + Math.sin(angle) * 200, 0.3, (v) => { orb.pos.y = v })
|
|
k.tween(0.8, 0, 0.3, (v) => { orb.opacity = v }).then(() => { if (orb.exists()) orb.destroy() })
|
|
}
|
|
await k.wait(0.2)
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
|
|
])
|
|
},
|
|
// 4: Lightning flight — zigzag across the screen at super speed with lightning
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('special'); sfxZoomWhoosh()
|
|
f.opacity = 0.8
|
|
const pts = [
|
|
{ x: hx + dir * 60, y: hy - 80 },
|
|
{ x: W / 2, y: hy - 140 },
|
|
{ x: hx + dir * 180, y: hy - 60 },
|
|
{ x: W / 2, y: hy - 100 },
|
|
{ x: hx, y: hy },
|
|
]
|
|
for (const pt of pts) {
|
|
spawnAfterimages(f, 2)
|
|
await Promise.all([
|
|
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
|
|
])
|
|
// Lightning bolt at each stop
|
|
sfxZap()
|
|
const bolt = k.add([k.rect(2, 30 + Math.random() * 40), k.pos(f.pos.x, f.pos.y - 50), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(20), k.anchor('center'), k.rotate(Math.random() * 30 - 15)])
|
|
k.tween(0.9, 0, 0.15, (v) => { bolt.opacity = v }).then(() => { if (bolt.exists()) bolt.destroy() })
|
|
}
|
|
f.opacity = 1
|
|
},
|
|
// 5: Satoshi meditation — float to center, golden aura pulse, wisdom text
|
|
async (f, hx, hy, sx, sy) => {
|
|
sfxPowerUp(); f.play('special')
|
|
await Promise.all([
|
|
k.tween(f.pos.x, W / 2, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(f.pos.y, hy - 70, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
|
|
])
|
|
// Golden aura rings
|
|
for (let i = 0; i < 3; i++) {
|
|
const ring = k.add([k.circle(10), k.pos(f.pos.x, f.pos.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.5), k.z(9), k.anchor('center'), k.scale(1)])
|
|
k.tween(1, (60 + i * 20) / 10, 0.4, (v) => { ring.scale.x = v; ring.scale.y = v })
|
|
k.tween(0.5, 0, 0.4, (v) => { ring.opacity = v }).then(() => { if (ring.exists()) ring.destroy() })
|
|
await k.wait(0.12)
|
|
}
|
|
// Scale pulse
|
|
await k.tween(1, 1.2, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
|
|
const sayings = ['PROOF OF WORK', 'VERIFY, DON\'T TRUST', '21 MILLION', 'TICK TOCK NEXT BLOCK', 'NOT YOUR KEYS', 'STAY HUMBLE STACK SATS']
|
|
spawnEmoteText(f.pos.x, f.pos.y - 50, sayings[Math.floor(Math.random() * sayings.length)], '#ffd700')
|
|
await k.wait(0.4)
|
|
await k.tween(1.2, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
|
|
])
|
|
},
|
|
// 6: Golden tornado — spin rapidly while moving in a circle across the arena
|
|
async (f, hx, hy) => {
|
|
sfxZoomWhoosh(); f.play('special')
|
|
const cx = W / 2, cy = hy - 50, radius = 100
|
|
const dur = 0.8
|
|
let lastParticle = 0
|
|
await k.tween(0, Math.PI * 2, dur, (a) => {
|
|
f.pos.x = cx + Math.cos(a) * radius
|
|
f.pos.y = cy + Math.sin(a) * radius * 0.4
|
|
f.angle = (a / (Math.PI * 2)) * 1440 // 4 full spins
|
|
if (a - lastParticle > 0.3) {
|
|
lastParticle = a
|
|
const p = k.add([k.text('₿', { size: 5 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y), k.color(safeColor(k, '#ffd700')), k.opacity(0.6), k.z(9), k.anchor('center')])
|
|
k.tween(0.6, 0, 0.4, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
|
|
}
|
|
})
|
|
f.angle = 0
|
|
sfxExplosion(); k.shake(6)
|
|
spawnShockwave(f.pos.x, f.pos.y, '#ffd700')
|
|
f.pos.x = hx; f.pos.y = hy
|
|
},
|
|
// 7: Laptop hack — walk forward, pull out laptop (rect), type furiously, spawn effects
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('special')
|
|
await k.tween(f.pos.x, hx + dir * 50, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
// Laptop
|
|
const cs = SF
|
|
const laptop = k.add([k.rect(24 * cs, 16 * cs), k.pos(f.pos.x + dir * 15, hy - 20 * cs), k.anchor('center'), k.color(safeColor(k, '#333333')), k.opacity(0.9), k.z(12)])
|
|
const screen = k.add([k.rect(20 * cs, 12 * cs), k.pos(f.pos.x + dir * 15, hy - 22 * cs), k.anchor('center'), k.color(safeColor(k, '#00ff00')), k.opacity(0.7), k.z(13)])
|
|
// Type — spawn code characters
|
|
const codeChars = '₿{}();=>0x1f'.split('')
|
|
for (let i = 0; i < 8; i++) {
|
|
const ch = codeChars[Math.floor(Math.random() * codeChars.length)]
|
|
const t = k.add([k.text(ch, { size: 5 }), k.pos(screen.pos.x + (Math.random() - 0.5) * 15, screen.pos.y), k.color(safeColor(k, '#00ff00')), k.opacity(0.8), k.z(14), k.anchor('center')])
|
|
k.tween(t.pos.y, t.pos.y - 25 - Math.random() * 20, 0.3, (v) => { t.pos.y = v })
|
|
k.tween(0.8, 0, 0.3, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() })
|
|
sfxCoin()
|
|
await k.wait(0.06)
|
|
}
|
|
spawnEmoteText(f.pos.x, f.pos.y - 55, 'HACKING...', '#00ff00')
|
|
await k.wait(0.2)
|
|
laptop.destroy(); screen.destroy()
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
// 8: Bitcoin rain shower — fly across dropping ₿ coins everywhere
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
sfxPowerUp(); f.play('win')
|
|
await k.tween(f.pos.y, hy - 90, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
|
|
// Fly from one side to the other
|
|
const startX = dir > 0 ? 30 : W - 30
|
|
const endX = dir > 0 ? W - 30 : 30
|
|
f.pos.x = startX
|
|
const dur = 0.6
|
|
let lastCoin = 0
|
|
await k.tween(0, 1, dur, (t) => {
|
|
f.pos.x = startX + (endX - startX) * t
|
|
f.pos.y = hy - 90 + Math.sin(t * Math.PI * 4) * 15
|
|
if (t - lastCoin > 0.04) {
|
|
lastCoin = t
|
|
const coin = k.add([k.text('₿', { size: 8 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 15), k.color(safeColor(k, Math.random() > 0.5 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(9), k.anchor('center')])
|
|
k.tween(coin.pos.y, hy + 10, 0.4, (v) => { coin.pos.y = v })
|
|
k.tween(coin.pos.x, coin.pos.x + (Math.random() - 0.5) * 30, 0.4, (v) => { coin.pos.x = v })
|
|
k.tween(0.8, 0, 0.4, (v) => { coin.opacity = v }).then(() => { if (coin.exists()) coin.destroy() })
|
|
}
|
|
})
|
|
sfxCoin()
|
|
f.pos.x = hx
|
|
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
},
|
|
// 9: Dimension rift — creator tears open a golden rift, steps through, appears elsewhere
|
|
async (f, hx, hy) => {
|
|
// Rift opens
|
|
sfxSpecial()
|
|
const riftX = f.pos.x + 30
|
|
const rift = k.add([k.rect(4, 50), k.pos(riftX, hy - 25), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(15)])
|
|
await k.tween(4, 30, 0.2, (v) => { rift.width = v })
|
|
spawnSparks(riftX, hy - 25, 8, '#ffd700')
|
|
// Step through — vanish
|
|
f.play('special')
|
|
await k.tween(f.pos.x, riftX, 0.1, (v) => { f.pos.x = v })
|
|
f.opacity = 0; sfxZap()
|
|
// Rift at destination
|
|
const destX = W - riftX
|
|
const rift2 = k.add([k.rect(30, 50), k.pos(destX, hy - 25), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(15)])
|
|
spawnSparks(destX, hy - 25, 8, '#ffd700')
|
|
await k.wait(0.15)
|
|
// Step out
|
|
f.pos.x = destX; f.opacity = 1; f.play('win')
|
|
sfxSpecial()
|
|
await k.wait(0.2)
|
|
// Close rifts
|
|
k.tween(0.9, 0, 0.2, (v) => { rift.opacity = v; rift2.opacity = v }).then(() => { if (rift.exists()) rift.destroy(); if (rift2.exists()) rift2.destroy() })
|
|
// Float home
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(f.pos.y, hy - 40, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
},
|
|
// 10: Golden ascension — rise way up, glow, descend with shockwave
|
|
async (f, hx, hy, sx, sy) => {
|
|
sfxPowerUp(); f.play('special')
|
|
await k.tween(f.pos.y, hy - 160, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
|
|
// Glow pulse
|
|
await k.tween(1, 1.5, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
|
|
screenFlash('#ffd700', 0.06)
|
|
// Spawn golden aura particles
|
|
for (let i = 0; i < 10; i++) {
|
|
const p = k.add([k.text('✦', { size: 4 + Math.random() * 6 }), k.pos(f.pos.x + (Math.random() - 0.5) * 40, f.pos.y + (Math.random() - 0.5) * 30), k.color(safeColor(k, '#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
|
|
k.tween(0.7, 0, 0.5, (v) => { p.opacity = v; p.pos.y -= 0.5 }).then(() => { if (p.exists()) p.destroy() })
|
|
}
|
|
spawnEmoteText(f.pos.x, f.pos.y - 40, 'HODL', '#ffd700')
|
|
await k.wait(0.3)
|
|
await k.tween(1.5, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
|
|
// Slam down
|
|
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(12)
|
|
spawnShockwave(hx, hy, '#ffd700')
|
|
spawnSparks(hx, hy, 15, '#ffd700')
|
|
await k.wait(0.15)
|
|
},
|
|
// 11: Phase walk — flicker opacity while walking through the opponent's space
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('special'); sfxZap()
|
|
const far = dir > 0 ? W - 40 : 40
|
|
const dur = 0.5
|
|
await k.tween(0, 1, dur, (t) => {
|
|
f.pos.x = hx + (far - hx) * t
|
|
f.opacity = 0.2 + Math.abs(Math.sin(t * Math.PI * 8)) * 0.7
|
|
})
|
|
f.opacity = 1; sfxSpecial()
|
|
screenFlash('#ffd700', 0.05)
|
|
spawnSparks(f.pos.x, hy - 20, 6, '#ffd700')
|
|
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 12: Bullet time attack — slow-mo dash with afterimages + golden bullet traces
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('attack')
|
|
// Darken screen for bullet time
|
|
const overlay = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(50)])
|
|
await k.tween(0, 0.35, 0.08, (v) => { overlay.opacity = v })
|
|
// Slow-mo lean back
|
|
await k.tween(0, -30, 0.2, (v) => { f.angle = v }, k.easings.easeOutQuad)
|
|
// Golden bullet traces fly past
|
|
for (let i = 0; i < 6; i++) {
|
|
const by = hy - 15 - Math.random() * 50
|
|
const bullet = k.add([k.rect(15, 2), k.pos(dir > 0 ? W + 10 : -10, by), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(51), k.anchor('center')])
|
|
const bdir = dir > 0 ? -1 : 1
|
|
k.tween(bullet.pos.x, bullet.pos.x + bdir * (W + 30), 0.35, (v) => { bullet.pos.x = v }).then(() => { if (bullet.exists()) bullet.destroy() })
|
|
sfxZoomWhoosh()
|
|
await k.wait(0.04)
|
|
}
|
|
await k.wait(0.1)
|
|
// Snap upright and dash through
|
|
await k.tween(-30, 0, 0.06, (v) => { f.angle = v })
|
|
f.play('special'); sfxZoomWhoosh()
|
|
// Dash with afterimages
|
|
const endX = hx + dir * 160
|
|
for (let i = 0; i < 6; i++) {
|
|
const ghostX = hx + (endX - hx) * (i / 6)
|
|
const ghost = k.add([k.rect(16, 30), k.pos(ghostX, hy - 15), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0.3 - i * 0.04), k.z(49)])
|
|
k.tween(0.3, 0, 0.4, (v) => { ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() })
|
|
}
|
|
await k.tween(f.pos.x, endX, 0.12, (v) => { f.pos.x = v }, k.easings.easeInQuad)
|
|
screenFlash('#ffd700', 0.06); sfxCritical(); k.shake(8)
|
|
spawnSparks(f.pos.x, hy - 20, 10, '#ffd700')
|
|
await k.wait(0.15)
|
|
// Zip back
|
|
await k.tween(overlay.opacity, 0, 0.1, (v) => { overlay.opacity = v })
|
|
overlay.destroy()
|
|
await k.tween(f.pos.x, hx, 0.1, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
|
|
},
|
|
// 13: ₿ throne summon — conjure golden throne, sit imperiously, dismiss
|
|
async (f, hx, hy) => {
|
|
sfxSpecial()
|
|
const cs = SF
|
|
// Throne pieces
|
|
const seat = k.add([k.rect(40 * cs, 8 * cs), k.pos(hx, hy - 5), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
|
|
const back = k.add([k.rect(35 * cs, 30 * cs), k.pos(hx, hy - 25 * cs), k.anchor('center'), k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(8)])
|
|
const armL = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx - 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
|
|
const armR = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx + 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(9)])
|
|
const parts = [seat, back, armL, armR]
|
|
// Fade in
|
|
for (const p of parts) k.tween(0, 0.8, 0.2, (v) => { p.opacity = v })
|
|
spawnSparks(hx, hy - 15, 6, '#ffd700')
|
|
await k.wait(0.25)
|
|
// Sit
|
|
f.play('win')
|
|
await k.tween(f.pos.y, hy - 12, 0.12, (v) => { f.pos.y = v }, k.easings.easeInQuad)
|
|
spawnEmoteText(hx, f.pos.y - 50, 'kneel', '#ffd700')
|
|
await k.wait(0.6)
|
|
// Stand and dismiss
|
|
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v })
|
|
for (const p of parts) k.tween(p.opacity, 0, 0.2, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
|
|
await k.wait(0.2)
|
|
},
|
|
// 14: Disco dance — Creator gets down with golden disco moves
|
|
async (f, hx, hy, _sx, _sy, dir) => {
|
|
f.play('win')
|
|
const colors = ['#ffd700', '#ff8c00', '#ffee88', '#c8a000', '#ffffff', '#ffd700']
|
|
spawnEmoteText(f.pos.x, f.pos.y - 55, '~♪ SATOSHI SHUFFLE ♪~', '#ffd700')
|
|
for (let i = 0; i < 10; i++) {
|
|
const dx = (i % 2 === 0 ? 25 : -25) + (Math.random() - 0.5) * 15
|
|
f.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'win')
|
|
await Promise.all([
|
|
k.tween(f.pos.x, hx + dx, 0.06, (v) => { f.pos.x = v }),
|
|
k.tween(f.pos.y, hy - 15, 0.03, (v) => { f.pos.y = v }).then(() =>
|
|
k.tween(f.pos.y, hy, 0.03, (v) => { f.pos.y = v })),
|
|
])
|
|
spawnSparks(f.pos.x, hy - 10, 2, colors[i % colors.length])
|
|
}
|
|
f.pos.x = hx
|
|
},
|
|
]
|
|
|
|
// Showboat loop: picks random showboats in sequence while active
|
|
async function _showboatLoop(side: 'a' | 'b') {
|
|
const tag = side === 'a' ? 'fighterA' : 'fighterB'
|
|
const homeX = side === 'a' ? HOME_A : HOME_B
|
|
const homeY = GROUND_Y - 6
|
|
const dir = side === 'a' ? 1 : -1
|
|
const arch = side === 'a' ? botA.archetype : botB.archetype
|
|
const isCreator = arch === 'the_creator'
|
|
// Creator uses magical showboats 60% of the time, normal 40%
|
|
const pool = isCreator ? [...showboats, ...creatorShowboats, ...creatorShowboats] : showboats
|
|
while (_showboatActive[side]) {
|
|
const f = k.get(tag)[0]
|
|
if (!f) break
|
|
const sx = f.scale.x
|
|
const sy = f.scale.y
|
|
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]
|
|
if (ff) {
|
|
ff.pos.x = homeX; ff.pos.y = homeY
|
|
ff.scale.x = sx; ff.scale.y = sy
|
|
ff.angle = 0; ff.opacity = 1
|
|
ff.play('idle')
|
|
}
|
|
// Brief pause between showboats
|
|
if (_showboatActive[side]) await k.wait(0.15 + Math.random() * 0.35)
|
|
}
|
|
}
|
|
|
|
return {
|
|
k,
|
|
|
|
async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {},
|
|
|
|
showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) {
|
|
showSpeechBubble(side, text, duration)
|
|
},
|
|
|
|
startTalking(side: 'a' | 'b') { startTalking(side) },
|
|
stopTalking(side: 'a' | 'b') { stopTalking(side) },
|
|
|
|
startShowboating(side: 'a' | 'b') {
|
|
if (_showboatActive[side]) return
|
|
_showboatActive[side] = true
|
|
_showboatLoop(side)
|
|
},
|
|
stopShowboating(side: 'a' | 'b') {
|
|
_showboatActive[side] = false
|
|
},
|
|
|
|
async playEntrance() {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
|
|
const entrances = [
|
|
// 0: Drive in with a car
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const dir = fromLeft ? 1 : -1
|
|
const startX = fromLeft ? -120 : W + 120
|
|
const carY = GROUND_Y - 20
|
|
const carBody = k.add([k.rect(80, 30), k.pos(startX, carY), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#ffcc00', '#39ff14', '#ff6600'][Math.floor(Math.random() * 5)])), k.z(9), k.opacity(1)])
|
|
const wheel1 = k.add([k.circle(8), k.pos(startX - 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)])
|
|
const wheel2 = k.add([k.circle(8), k.pos(startX + 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)])
|
|
fighter.pos.x = startX
|
|
fighter.pos.y = carY - 30
|
|
fighter.opacity = 1
|
|
sfxZoomWhoosh()
|
|
await k.tween(startX, homeX, 0.5, (v) => {
|
|
carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir; fighter.pos.x = v
|
|
}, k.easings.easeOutQuad)
|
|
sfxBlock()
|
|
k.shake(6)
|
|
// Jump out
|
|
await k.tween(fighter.pos.y, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk()
|
|
// Drive car away
|
|
k.tween(carBody.pos.x, fromLeft ? W + 150 : -150, 0.4, (v) => {
|
|
carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir
|
|
}, k.easings.easeInQuad).then(() => { carBody.destroy(); wheel1.destroy(); wheel2.destroy() })
|
|
},
|
|
// 1: Fall from space
|
|
async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
fighter.pos.x = homeX
|
|
fighter.pos.y = -200
|
|
fighter.opacity = 1
|
|
sfxZoomWhoosh()
|
|
// Trail of fire
|
|
const trail: any[] = []
|
|
for (let i = 0; i < 6; i++) {
|
|
setTimeout(() => {
|
|
const t = k.add([k.circle(4 + Math.random() * 6), k.pos(homeX + (Math.random() - 0.5) * 20, fighter.pos.y + 20), k.color(safeColor(k,i < 3 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(9)])
|
|
trail.push(t)
|
|
k.tween(t.opacity, 0, 0.4, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() })
|
|
}, i * 40)
|
|
}
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
sfxExplosion()
|
|
k.shake(15)
|
|
screenFlash('#ff6600', 0.15)
|
|
spawnShockwave(homeX, GROUND_Y, '#ff6600')
|
|
spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00')
|
|
trail.forEach(t => { if (t.exists()) t.destroy() })
|
|
},
|
|
// 2: Robe entrance (boxing style)
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -60 : W + 60
|
|
fighter.pos.x = startX
|
|
fighter.pos.y = GROUND_Y - 6
|
|
fighter.opacity = 1
|
|
// Robe overlay
|
|
const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)]
|
|
const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,robeColor)), k.opacity(0.85), k.z(11)])
|
|
announceDeepIntro()
|
|
// Slow walk in
|
|
await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad)
|
|
await k.wait(0.3)
|
|
// Take off robe (fly up and fade)
|
|
sfxSpecial()
|
|
await k.tween(robe.pos.y, robe.pos.y - 100, 0.3, (v) => { robe.pos.y = v; robe.opacity = Math.max(0, 1 - (robe.pos.y - GROUND_Y + 130) / -100) }, k.easings.easeOutQuad)
|
|
robe.destroy()
|
|
k.shake(3)
|
|
spawnSparks(homeX, GROUND_Y - 40, 8, robeColor)
|
|
},
|
|
// 3: Girlfriend argument
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const dir = fromLeft ? 1 : -1
|
|
const startX = fromLeft ? -60 : W + 60
|
|
fighter.pos.x = startX
|
|
fighter.pos.y = GROUND_Y - 6
|
|
fighter.opacity = 1
|
|
// Girlfriend silhouette
|
|
const gfX = startX + dir * 40
|
|
const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor(k,'#ff69b4')), k.opacity(0.9), k.z(11)])
|
|
const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor(k,'#ff0000')), k.z(12)])
|
|
announceSilly('We need to talk! About your ELO!')
|
|
// Walk in arguing
|
|
await k.tween(startX, homeX + dir * 30, 0.6, (v) => {
|
|
fighter.pos.x = v; gf.pos.x = v + dir * 40; heart.pos.x = v + dir * 40
|
|
}, k.easings.easeInOutQuad)
|
|
await k.wait(0.2)
|
|
heart.text = '!!!'
|
|
announceRandom("You promised you'd stop fighting!")
|
|
await k.wait(0.4)
|
|
// Girlfriend storms off
|
|
announceSilly('I\'m telling your developer!')
|
|
await k.tween(gf.pos.x, fromLeft ? -60 : W + 60, 0.5, (v) => { gf.pos.x = v; heart.pos.x = v }, k.easings.easeInQuad)
|
|
gf.destroy(); heart.destroy()
|
|
// Bot shrugs and walks to position
|
|
await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeInOutQuad)
|
|
sfxBoing()
|
|
},
|
|
// 4: Helicopter drop
|
|
async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
fighter.pos.x = homeX
|
|
fighter.pos.y = -100
|
|
fighter.opacity = 1
|
|
// Helicopter body
|
|
const heli = k.add([k.rect(60, 20), k.pos(homeX, -80), k.anchor('center'), k.color(safeColor(k,'#555555')), k.z(12)])
|
|
const blade = k.add([k.rect(80, 3), k.pos(homeX, -95), k.anchor('center'), k.color(safeColor(k,'#888888')), k.z(13), k.rotate(0)])
|
|
blade.onUpdate(() => { blade.angle += 720 * k.dt() })
|
|
sfxJetpack()
|
|
// Descend
|
|
await k.tween(-80, GROUND_Y - 80, 0.6, (v) => {
|
|
heli.pos.y = v; blade.pos.y = v - 15; fighter.pos.y = v + 30
|
|
}, k.easings.easeInOutQuad)
|
|
// Drop
|
|
sfxZoomWhoosh()
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.2, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); k.shake(5)
|
|
// Helicopter flies away
|
|
k.tween(heli.pos.y, -200, 0.5, (v) => { heli.pos.y = v; blade.pos.y = v - 15 }, k.easings.easeInQuad)
|
|
.then(() => { heli.destroy(); blade.destroy() })
|
|
},
|
|
// 5: Teleport glitch
|
|
async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
fighter.opacity = 0
|
|
sfxZap()
|
|
// Glitch flickers at random positions
|
|
for (let i = 0; i < 6; i++) {
|
|
fighter.pos.x = homeX + (Math.random() - 0.5) * 200
|
|
fighter.pos.y = GROUND_Y - 6 + (Math.random() - 0.5) * 80
|
|
fighter.opacity = 0.4
|
|
scanlineGlitch(0.05)
|
|
await k.wait(0.06)
|
|
fighter.opacity = 0
|
|
await k.wait(0.04)
|
|
}
|
|
fighter.pos.x = homeX
|
|
fighter.pos.y = GROUND_Y - 6
|
|
fighter.opacity = 1
|
|
sfxZap()
|
|
screenFlash('#00f0ff', 0.1)
|
|
spawnSparks(homeX, GROUND_Y - 30, 10, '#00f0ff')
|
|
k.shake(5)
|
|
},
|
|
// 6: Skateboard ride in
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -80 : W + 80
|
|
const board = k.add([k.rect(40, 6), k.pos(startX, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#884422')), k.z(9)])
|
|
const wheelL = k.add([k.circle(4), k.pos(startX - 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)])
|
|
const wheelR = k.add([k.circle(4), k.pos(startX + 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)])
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 14; fighter.opacity = 1
|
|
sfxZoomWhoosh()
|
|
await k.tween(startX, homeX, 0.5, (v) => {
|
|
fighter.pos.x = v; board.pos.x = v; wheelL.pos.x = v - 14; wheelR.pos.x = v + 14
|
|
fighter.pos.y = GROUND_Y - 14 + Math.sin((v - startX) * 0.1) * 3
|
|
}, k.easings.easeOutQuad)
|
|
// Kickflip off
|
|
sfxBoing()
|
|
await k.tween(fighter.pos.y, GROUND_Y - 60, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad)
|
|
board.destroy(); wheelL.destroy(); wheelR.destroy()
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); k.shake(3)
|
|
},
|
|
// 7: Rocket jetpack
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -80 : W + 80
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 80; fighter.opacity = 1
|
|
sfxJetpack()
|
|
// Fly in with flame trail
|
|
const flames: any[] = []
|
|
const interval = setInterval(() => {
|
|
const f = k.add([k.circle(5 + Math.random() * 5), k.pos(fighter.pos.x, fighter.pos.y + 25), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(9)])
|
|
flames.push(f)
|
|
k.tween(f.opacity, 0, 0.3, (v) => { f.opacity = v }).then(() => { if (f.exists()) f.destroy() })
|
|
}, 30)
|
|
await k.tween(startX, homeX, 0.4, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad)
|
|
clearInterval(interval)
|
|
// Land
|
|
sfxExplosion()
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
k.shake(8)
|
|
spawnSparks(homeX, GROUND_Y - 10, 10, '#ff6600')
|
|
flames.forEach(f => { if (f.exists()) f.destroy() })
|
|
},
|
|
// 8: Emerge from portal
|
|
async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
const portalColor = ['#b83dff', '#00f0ff', '#39ff14', '#ff2d7b'][Math.floor(Math.random() * 4)]
|
|
// Draw portal
|
|
const portal = k.add([k.circle(35), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,portalColor)), k.opacity(0), k.z(9)])
|
|
const portalRing = k.add([k.circle(40), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,'#ffffff')), k.opacity(0), k.z(8)])
|
|
sfxZap()
|
|
await k.tween(0, 0.7, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeOutQuad)
|
|
fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 30
|
|
fighter.opacity = 0
|
|
await k.tween(0, 1, 0.3, (v) => { fighter.opacity = v; fighter.pos.y = GROUND_Y - 30 + (GROUND_Y - 6 - GROUND_Y + 30) * v }, k.easings.easeOutQuad)
|
|
fighter.pos.y = GROUND_Y - 6
|
|
fighter.opacity = 1
|
|
sfxSpecial()
|
|
spawnSparks(homeX, GROUND_Y - 20, 10, portalColor)
|
|
await k.tween(portal.opacity, 0, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeInQuad)
|
|
portal.destroy(); portalRing.destroy()
|
|
},
|
|
// 9: Backflip entrance
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -40 : W + 40
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
|
|
sfxZoomWhoosh()
|
|
const flipCount = 2 + Math.floor(Math.random() * 2)
|
|
const totalDist = Math.abs(homeX - startX)
|
|
const flipDist = totalDist / flipCount
|
|
for (let i = 0; i < flipCount; i++) {
|
|
const from = startX + (fromLeft ? 1 : -1) * flipDist * i
|
|
const to = startX + (fromLeft ? 1 : -1) * flipDist * (i + 1)
|
|
await Promise.all([
|
|
k.tween(from, to, 0.2, (v) => { fighter.pos.x = v }, k.easings.linear),
|
|
k.tween(GROUND_Y - 6, GROUND_Y - 70, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(GROUND_Y - 70, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
if (i < flipCount - 1) sfxBoing()
|
|
}
|
|
sfxBonk(); k.shake(4)
|
|
spawnSparks(homeX, GROUND_Y - 10, 6, '#ffe14d')
|
|
},
|
|
// 10: Rise from underground
|
|
async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
fighter.pos.x = homeX; fighter.pos.y = GROUND_Y + 60; fighter.opacity = 0.5
|
|
// Crack the ground
|
|
const crack1 = k.add([k.rect(3, 15), k.pos(homeX - 10, GROUND_Y - 5), k.color(safeColor(k,'#ffcc00')), k.opacity(0.7), k.z(9), k.rotate(15)])
|
|
const crack2 = k.add([k.rect(3, 12), k.pos(homeX + 8, GROUND_Y - 3), k.color(safeColor(k,'#ffcc00')), k.opacity(0.6), k.z(9), k.rotate(-20)])
|
|
sfxExplosion(); k.shake(8)
|
|
await k.wait(0.2)
|
|
// Rise up
|
|
await k.tween(GROUND_Y + 60, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v; fighter.opacity = Math.min(1, (GROUND_Y + 60 - v) / 60) }, k.easings.easeOutQuad)
|
|
fighter.opacity = 1
|
|
spawnSparks(homeX, GROUND_Y - 10, 12, '#aa8833')
|
|
crack1.destroy(); crack2.destroy()
|
|
},
|
|
// 11: Slide in on ice
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -80 : W + 80
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
|
|
sfxSlideDown()
|
|
// Ice trail
|
|
const iceTrail: any[] = []
|
|
await k.tween(startX, homeX + (fromLeft ? 40 : -40), 0.4, (v) => {
|
|
fighter.pos.x = v
|
|
if (Math.random() < 0.3) {
|
|
const ice = k.add([k.rect(8, 3), k.pos(v, GROUND_Y - 2), k.color(safeColor(k,'#aaeeff')), k.opacity(0.5), k.z(1)])
|
|
iceTrail.push(ice)
|
|
}
|
|
}, k.easings.easeOutQuad)
|
|
// Slide to stop
|
|
await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutCubic)
|
|
sfxBlock(); k.shake(3)
|
|
setTimeout(() => { iceTrail.forEach(i => { if (i.exists()) i.destroy() }) }, 800)
|
|
},
|
|
// 12: Parachute drop
|
|
async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
fighter.pos.x = homeX + (Math.random() - 0.5) * 60; fighter.pos.y = -120; fighter.opacity = 1
|
|
const chute = k.add([k.circle(30), k.pos(fighter.pos.x, fighter.pos.y - 35), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00'][Math.floor(Math.random() * 4)])), k.opacity(0.8), k.z(12)])
|
|
const line1 = k.add([k.rect(1, 30), k.pos(fighter.pos.x - 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)])
|
|
const line2 = k.add([k.rect(1, 30), k.pos(fighter.pos.x + 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)])
|
|
// Float down
|
|
await k.tween(-120, GROUND_Y - 50, 0.7, (v) => {
|
|
fighter.pos.y = v; chute.pos.y = v - 35; line1.pos.y = v - 20; line2.pos.y = v - 20
|
|
fighter.pos.x += Math.sin(v * 0.05) * 0.5
|
|
chute.pos.x = fighter.pos.x; line1.pos.x = fighter.pos.x - 10; line2.pos.x = fighter.pos.x + 10
|
|
}, k.easings.easeInOutQuad)
|
|
// Cut chute
|
|
chute.destroy(); line1.destroy(); line2.destroy()
|
|
sfxZoomWhoosh()
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
await k.tween(fighter.pos.x, homeX, 0.2, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad)
|
|
sfxBonk(); k.shake(4)
|
|
},
|
|
// 13: Moonwalk in
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -60 : W + 60
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
|
|
announceCool('Smoother than a politician changing positions!')
|
|
// Moonwalk: visually moving backward but translating forward
|
|
fighter.scale.x = -fighter.scale.x // Face away
|
|
await k.tween(startX, homeX, 0.8, (v) => {
|
|
fighter.pos.x = v
|
|
fighter.pos.y = GROUND_Y - 6 + Math.sin((v - startX) * 0.15) * 3
|
|
}, k.easings.easeInOutQuad)
|
|
fighter.scale.x = -fighter.scale.x // Face right way
|
|
sfxBoing(); k.shake(2)
|
|
},
|
|
// 14: Thrown in by bouncer
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const doorX = fromLeft ? -30 : W + 30
|
|
fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
|
|
// Bouncer arm
|
|
const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor(k,'#444444')), k.z(12)])
|
|
announceSilly('And stay out! You\'re BANNED from the other fight!')
|
|
await k.wait(0.3)
|
|
// Throw
|
|
sfxZoomWhoosh()
|
|
arm.destroy()
|
|
await Promise.all([
|
|
k.tween(doorX, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(GROUND_Y - 6, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(GROUND_Y - 80, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxBonk(); k.shake(6)
|
|
spawnSparks(homeX, GROUND_Y - 10, 6, '#ff6600')
|
|
},
|
|
// 15: Lightning strike entrance
|
|
async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 0
|
|
// Lightning bolt from sky
|
|
const bolt = k.add([k.rect(4, H), k.pos(homeX, 0), k.color(safeColor(k,'#ffff44')), k.opacity(0.9), k.z(20)])
|
|
sfxZap(); screenFlash('#ffffff', 0.15); k.shake(12)
|
|
await k.wait(0.1)
|
|
bolt.destroy()
|
|
// Smoke / reveal
|
|
for (let i = 0; i < 8; i++) {
|
|
const smoke = k.add([k.circle(10 + Math.random() * 15), k.pos(homeX + (Math.random() - 0.5) * 40, GROUND_Y - 20 - Math.random() * 30), k.color(safeColor(k,'#aaaaaa')), k.opacity(0.6), k.z(11)])
|
|
k.tween(smoke.opacity, 0, 0.5, (v) => { smoke.opacity = v; smoke.pos.y -= 1 }).then(() => { if (smoke.exists()) smoke.destroy() })
|
|
}
|
|
await k.wait(0.3)
|
|
fighter.opacity = 1
|
|
sfxSpecial()
|
|
spawnSparks(homeX, GROUND_Y - 20, 10, '#ffff44')
|
|
},
|
|
// 16: Crowd surf in
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -60 : W + 60
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 50; fighter.opacity = 1
|
|
// Crowd hands
|
|
const hands: any[] = []
|
|
for (let i = 0; i < 8; i++) {
|
|
const hx = startX + (fromLeft ? 1 : -1) * (Math.abs(homeX - startX) / 8) * i
|
|
const h = k.add([k.rect(6, 20), k.pos(hx, GROUND_Y - 10), k.anchor('bot'), k.color(safeColor(k,'#cc9966')), k.z(8)])
|
|
hands.push(h)
|
|
}
|
|
announceCrowdReaction('cheer')
|
|
await k.tween(startX, homeX, 0.6, (v) => {
|
|
fighter.pos.x = v
|
|
fighter.pos.y = GROUND_Y - 50 + Math.sin((v - startX) * 0.08) * 10
|
|
}, k.easings.easeOutQuad)
|
|
// Drop down
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); k.shake(3)
|
|
hands.forEach(h => { if (h.exists()) h.destroy() })
|
|
},
|
|
// 17: Riding a shopping cart
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -100 : W + 100
|
|
const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor(k,'#888888')), k.opacity(0.9), k.z(9)])
|
|
const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#444')), k.z(9)])
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1
|
|
announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!')
|
|
sfxZoomWhoosh()
|
|
await k.tween(startX, homeX, 0.5, (v) => {
|
|
fighter.pos.x = v; cartBody.pos.x = v; cartWheel.pos.x = v + (fromLeft ? 15 : -15)
|
|
fighter.pos.y = GROUND_Y - 40 + Math.sin((v - startX) * 0.08) * 5
|
|
}, k.easings.easeOutQuad)
|
|
// Crash and tumble out
|
|
sfxBonk(); k.shake(6)
|
|
cartBody.destroy(); cartWheel.destroy()
|
|
await k.tween(fighter.pos.y, GROUND_Y - 50, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(fighter.pos.y, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
spawnSparks(homeX, GROUND_Y - 10, 5, '#888888')
|
|
},
|
|
// 18: Dramatic slow walk with spotlight
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const startX = fromLeft ? -60 : W + 60
|
|
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
|
|
// Spotlight cone
|
|
const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor(k,'#ffe14d')), k.opacity(0.08), k.z(1)])
|
|
announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!')
|
|
await k.tween(startX, homeX, 1.0, (v) => {
|
|
fighter.pos.x = v; spot.pos.x = v - 30
|
|
}, k.easings.easeInOutQuad)
|
|
await k.wait(0.2)
|
|
spot.destroy()
|
|
sfxSpecial(); k.shake(3)
|
|
spawnSparks(homeX, GROUND_Y - 30, 8, '#ffe14d')
|
|
},
|
|
// 19: Cannon launch
|
|
async (fighter: any, homeX: number, fromLeft: boolean) => {
|
|
const cannonX = fromLeft ? -40 : W + 40
|
|
// Draw cannon
|
|
const cannon = k.add([k.rect(50, 25), k.pos(cannonX, GROUND_Y - 20), k.anchor('center'), k.color(safeColor(k,'#333333')), k.z(9), k.rotate(fromLeft ? -30 : 210)])
|
|
fighter.pos.x = cannonX; fighter.pos.y = GROUND_Y - 20; fighter.opacity = 0
|
|
await k.wait(0.3)
|
|
// Fire!
|
|
sfxGunshot(); sfxExplosion()
|
|
fighter.opacity = 1
|
|
screenFlash('#ffcc00', 0.1)
|
|
const flashCircle = k.add([k.circle(20), k.pos(cannonX + (fromLeft ? 25 : -25), GROUND_Y - 35), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(12)])
|
|
setTimeout(() => { if (flashCircle.exists()) flashCircle.destroy() }, 80)
|
|
// Arc to position
|
|
await Promise.all([
|
|
k.tween(cannonX, homeX, 0.35, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(GROUND_Y - 20, GROUND_Y - 120, 0.17, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(GROUND_Y - 120, GROUND_Y - 6, 0.18, (v) => { fighter.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxBonk(); k.shake(10)
|
|
spawnShockwave(homeX, GROUND_Y, '#ff6600')
|
|
spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00')
|
|
cannon.destroy()
|
|
},
|
|
]
|
|
|
|
// THE CREATOR: Golden code rain portal entrance
|
|
const creatorEntrance = async (fighter: any, homeX: number, _fromLeft: boolean) => {
|
|
fighter.pos.x = homeX
|
|
fighter.pos.y = -100
|
|
fighter.opacity = 0
|
|
// Golden code rain columns
|
|
const codeChars = '₿01∞⚡⛏§∂∆≈≠'.split('')
|
|
const rainDrops: any[] = []
|
|
for (let col = 0; col < 12; col++) {
|
|
const cx = homeX - 60 + col * 10
|
|
for (let row = 0; row < 4; row++) {
|
|
const ch = codeChars[Math.floor(Math.random() * codeChars.length)]
|
|
const rd = k.add([
|
|
k.text(ch, { size: 8 }), k.pos(cx, -20 - row * 25),
|
|
k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')),
|
|
k.opacity(0.6 + Math.random() * 0.4), k.z(48), k.anchor('center'),
|
|
])
|
|
rainDrops.push(rd)
|
|
k.tween(rd.pos.y, GROUND_Y + 20, 0.6 + Math.random() * 0.3, (v) => {
|
|
rd.pos.y = v
|
|
rd.opacity = Math.max(0, 1 - (v - GROUND_Y + 30) / 50)
|
|
}).then(() => { if (rd.exists()) rd.destroy() })
|
|
}
|
|
}
|
|
await k.wait(0.3)
|
|
// Portal flash
|
|
screenFlash('#ffd700', 0.15)
|
|
spawnShockwave(homeX, GROUND_Y - 30, '#c8a000')
|
|
sfxSpecial()
|
|
// Fighter descends through golden portal
|
|
fighter.opacity = 1
|
|
await k.tween(-100, GROUND_Y - 6, 0.5, (v) => {
|
|
fighter.pos.y = v
|
|
}, k.easings.easeOutBack)
|
|
k.shake(12)
|
|
sfxExplosion()
|
|
// Persistent orbiting ₿ letters around creator (always visible)
|
|
for (let i = 0; i < 10; i++) {
|
|
const ring = i < 6 ? 0 : 1
|
|
const ringIdx = ring === 0 ? i : i - 6
|
|
const ringCount = ring === 0 ? 6 : 4
|
|
const sz = ring === 0 ? 7 + Math.random() * 3 : 10 + Math.random() * 4
|
|
const rad = ring === 0 ? 25 + i * 4 : 45 + (i - 6) * 6
|
|
const spd = ring === 0 ? 2 + i * 0.3 : -(1.5 + (i - 6) * 0.4)
|
|
const colors = ['#ffd700', '#ffee88', '#ff8c00', '#c8a000', '#ffffff']
|
|
const p = k.add([
|
|
k.text('₿', { size: sz }), k.pos(homeX, GROUND_Y - 30),
|
|
k.color(safeColor(k, colors[i % colors.length])),
|
|
k.opacity(0.5), k.z(11), k.anchor('center'), k.rotate(0),
|
|
])
|
|
p.onUpdate(() => {
|
|
const a = k.time() * spd + ringIdx * (Math.PI * 2 / ringCount)
|
|
p.pos.x = fighter.pos.x + Math.cos(a) * rad
|
|
p.pos.y = fighter.pos.y - 25 + Math.sin(a) * rad * 0.45
|
|
p.opacity = 0.35 + Math.sin(k.time() * 5 + i * 1.2) * 0.25
|
|
p.angle = Math.sin(k.time() * 3 + i) * 15
|
|
})
|
|
}
|
|
announceCreatorEntrance()
|
|
rainDrops.forEach(r => { if (r.exists()) r.destroy() })
|
|
}
|
|
|
|
// Pick random entrance for each bot (different ones)
|
|
const idxA = Math.floor(Math.random() * entrances.length)
|
|
let idxB = Math.floor(Math.random() * entrances.length)
|
|
while (idxB === idxA && entrances.length > 1) idxB = Math.floor(Math.random() * entrances.length)
|
|
|
|
// Play entrances with slight stagger — creator always gets special entrance
|
|
announceDeepIntro()
|
|
if (botA.archetype === 'the_creator') {
|
|
await creatorEntrance(fA, HOME_A, true)
|
|
} else {
|
|
await entrances[idxA](fA, HOME_A, true)
|
|
}
|
|
await k.wait(0.3)
|
|
if (botB.archetype === 'the_creator') {
|
|
await creatorEntrance(fB, HOME_B, false)
|
|
} else {
|
|
await entrances[idxB](fB, HOME_B, false)
|
|
}
|
|
await k.wait(0.2)
|
|
|
|
// Safety: ensure both fighters are visible and at home positions
|
|
fA.pos.x = HOME_A
|
|
fA.pos.y = GROUND_Y - 6
|
|
fA.opacity = 1
|
|
fB.pos.x = HOME_B
|
|
fB.pos.y = GROUND_Y - 6
|
|
fB.opacity = 1
|
|
|
|
// Both idle at home positions
|
|
fA.play('idle')
|
|
fB.play('idle')
|
|
},
|
|
|
|
// === PHYSICAL CONTACT SYSTEMS ===
|
|
// Helper: spawn dizzy stars orbiting a fighter's head
|
|
_spawnDizzyStars(fighter: any, duration: number) {
|
|
const stars: any[] = []
|
|
const starChars = ['*', '\u2605', '\u00d7', '!', '?']
|
|
const starColors = ['#ffff00', '#ffffff', '#ff4444', '#44ff44', '#ff88ff']
|
|
for (let i = 0; i < 4; i++) {
|
|
const s = k.add([
|
|
k.text(starChars[i % starChars.length], { size: 8 }),
|
|
k.pos(fighter.pos.x, fighter.pos.y - 55),
|
|
k.color(safeColor(k, starColors[i % starColors.length])),
|
|
k.opacity(0.9), k.z(55), k.anchor('center'),
|
|
])
|
|
stars.push(s)
|
|
}
|
|
const startTime = k.time()
|
|
const cancel = k.onUpdate(() => {
|
|
const elapsed = k.time() - startTime
|
|
if (elapsed > duration || !fighter.exists()) {
|
|
stars.forEach(s => { if (s.exists()) s.destroy() })
|
|
cancel.cancel()
|
|
return
|
|
}
|
|
for (let i = 0; i < stars.length; i++) {
|
|
if (!stars[i].exists()) continue
|
|
const angle = elapsed * 4 + (i * Math.PI * 2) / stars.length
|
|
stars[i].pos.x = fighter.pos.x + Math.cos(angle) * 22
|
|
stars[i].pos.y = fighter.pos.y - 55 + Math.sin(angle) * 8
|
|
stars[i].opacity = Math.max(0, 1 - elapsed / duration)
|
|
}
|
|
})
|
|
},
|
|
|
|
// Helper: reset both fighters to home positions
|
|
async _resetPositions() {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
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),
|
|
k.tween(fA.pos.y, GROUND_Y, 0.15, (v) => { fA.pos.y = v }),
|
|
k.tween(fB.pos.y, GROUND_Y, 0.15, (v) => { fB.pos.y = v }),
|
|
])
|
|
fA.angle = 0; fB.angle = 0; fA.opacity = 1; fB.opacity = 1
|
|
fA.play('idle'); fB.play('idle')
|
|
},
|
|
|
|
// 0: RAPID BRAWL — close distance, trade rapid blows
|
|
async _brawlRapid(winningSide: 'a' | 'b' | null, intensity: number) {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
const midX = W / 2
|
|
sfxZoomWhoosh()
|
|
await Promise.all([
|
|
k.tween(fA.pos.x, midX - 30, 0.12, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(fB.pos.x, midX + 30, 0.12, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
|
|
])
|
|
const hitCount = 4 + Math.floor(intensity * 6) + Math.floor(Math.random() * 3)
|
|
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 aHits = winningSide === 'a' ? Math.random() < 0.65 : winningSide === 'b' ? Math.random() < 0.35 : Math.random() < 0.5
|
|
const atk = aHits ? fA : fB; const def = aHits ? fB : fA
|
|
atk.play(anims[i % anims.length]); sfxPunch(); def.play('hit')
|
|
k.shake(2 + Math.floor(intensity * 3))
|
|
spawnSparks(midX + (Math.random() - 0.5) * 20, GROUND_Y - 20 - Math.random() * 30, 3, hitColors[i % hitColors.length])
|
|
def.pos.x += (aHits ? 1 : -1) * (3 + Math.random() * 4)
|
|
await k.wait(0.04 + (1 - intensity) * 0.04)
|
|
}
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 1: KNOCKDOWN BRAWL — combo ends with loser knocked flat, has to get back up
|
|
async _brawlKnockdown(winningSide: 'a' | 'b' | null, intensity: number) {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
|
|
const loser = winner === fA ? fB : fA
|
|
const dir = winner === fA ? 1 : -1
|
|
|
|
// Rush in, quick combo
|
|
sfxZoomWhoosh()
|
|
const contactX = loser.pos.x - dir * 40
|
|
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
|
|
const hits = 3 + Math.floor(intensity * 3)
|
|
for (let i = 0; i < hits; i++) {
|
|
winner.play(i % 2 === 0 ? 'attack' : 'kick'); sfxPunch()
|
|
loser.play('hit'); k.shake(3 + i)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20 - Math.random() * 20, 3, '#ff6600')
|
|
loser.pos.x += dir * 5
|
|
await k.wait(0.05)
|
|
}
|
|
// Final hit — KNOCKDOWN
|
|
winner.play('special'); sfxCritical(); sfxExplosion()
|
|
loser.play('knockback'); k.shake(12)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 25, 12, '#ff2d2d')
|
|
screenFlash('#ff2d2d', 0.1)
|
|
if (Math.random() < 0.5) sfxRandomComedy()
|
|
// Loser falls to ground — scale Y to flatten, rotate
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.25, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(loser.pos.y, GROUND_Y + 5, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad),
|
|
k.tween(0, dir * 90, 0.25, (v) => { loser.angle = v }),
|
|
])
|
|
sfxBonk(); k.shake(6)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 20, 'DOWN!', '#ff4444')
|
|
// Loser lies there for a beat
|
|
await k.wait(0.4 + (1 - intensity) * 0.4)
|
|
// Slowly get back up — stagger, wobble
|
|
this._spawnDizzyStars(loser, 1.5)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ugh', '#aaaaaa')
|
|
await k.tween(loser.angle, 0, 0.3, (v) => { loser.angle = v }, k.easings.easeOutBack)
|
|
loser.play('hit')
|
|
// Stagger wobble while getting up
|
|
for (let i = 0; i < 3; i++) {
|
|
await k.tween(loser.pos.x, loser.pos.x + (i % 2 === 0 ? -12 : 12), 0.12, (v) => { loser.pos.x = v })
|
|
}
|
|
await k.wait(0.2)
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 2: WALL BOUNCE — punch sends fighter flying into screen edge, bounces off
|
|
async _brawlWallBounce(winningSide: 'a' | 'b' | null, intensity: number) {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
|
|
const loser = winner === fA ? fB : fA
|
|
const dir = winner === fA ? 1 : -1
|
|
const wallX = dir === 1 ? W - 15 : 15
|
|
|
|
// Rush in for the big hit
|
|
sfxZoomWhoosh()
|
|
await k.tween(winner.pos.x, loser.pos.x - dir * 40, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
|
|
winner.play('special'); sfxCritical()
|
|
await k.wait(0.06)
|
|
loser.play('knockback'); k.shake(15)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 25, 15, '#ff2d7b')
|
|
screenFlash('#ffffff', 0.08)
|
|
|
|
// Loser flies across to wall
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, wallX, 0.15, (v) => { loser.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(loser.pos.y, GROUND_Y - 30, 0.08, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(loser.pos.y, GROUND_Y, 0.07, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
// WALL IMPACT
|
|
sfxExplosion(); k.shake(20)
|
|
spawnShockwave(wallX, GROUND_Y, '#ff6600')
|
|
spawnSparks(wallX, GROUND_Y - 40, 15, '#ffcc00')
|
|
screenFlash('#ff6600', 0.12)
|
|
sfxBoneCrack()
|
|
spawnEmoteText(wallX, GROUND_Y - 70, 'WALL!', '#ff6600')
|
|
|
|
// Bounce off wall — loser rebounds back toward center
|
|
loser.play('hit')
|
|
const bounceX = wallX - dir * (60 + Math.random() * 40)
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(loser.pos.y, GROUND_Y - 50, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
k.tween(0, dir * -360, 0.2, (v) => { loser.angle = v }),
|
|
])
|
|
loser.angle = 0
|
|
sfxBonk(); k.shake(5)
|
|
|
|
// Dizzy stagger after wall bounce
|
|
this._spawnDizzyStars(loser, 1.2)
|
|
loser.play('hit')
|
|
for (let w = 0; w < 4; w++) {
|
|
await k.tween(loser.pos.x, loser.pos.x + (w % 2 === 0 ? -10 : 10), 0.1, (v) => { loser.pos.x = v })
|
|
}
|
|
await k.wait(0.3)
|
|
winner.play('win'); spawnEmoteText(winner.pos.x, winner.pos.y - 55, 'GET REKT', '#39ff14')
|
|
await k.wait(0.3)
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 3: DIZZY STAGGER — sustained combo leaves loser wobbling around the screen
|
|
async _brawlDizzyStagger(winningSide: 'a' | 'b' | null, intensity: number) {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
|
|
const loser = winner === fA ? fB : fA
|
|
const dir = winner === fA ? 1 : -1
|
|
|
|
// Rush in, big combo
|
|
sfxZoomWhoosh()
|
|
await k.tween(winner.pos.x, loser.pos.x - dir * 35, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
|
|
const combo = 5 + Math.floor(intensity * 4)
|
|
for (let i = 0; i < combo; i++) {
|
|
winner.play(['attack', 'kick', 'special'][i % 3]); sfxPunch()
|
|
loser.play('hit'); k.shake(3 + Math.floor(i * 0.5))
|
|
spawnSparks((winner.pos.x + loser.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 3, i % 2 === 0 ? '#ff2d7b' : '#ffcc00')
|
|
loser.pos.x += dir * 4
|
|
await k.wait(0.04)
|
|
}
|
|
// Final uppercut
|
|
winner.play('special'); sfxCritical(); k.shake(10)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ffe14d')
|
|
await k.tween(loser.pos.y, GROUND_Y - 40, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk()
|
|
|
|
// DIZZY PHASE — loser staggers around aimlessly
|
|
this._spawnDizzyStars(loser, 2.5)
|
|
loser.play('hit')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...wha?', '#ffff88')
|
|
winner.play('idle')
|
|
// Stagger path — zigzag drunkenly
|
|
const staggerPoints = [
|
|
loser.pos.x + dir * 30, loser.pos.x - dir * 50,
|
|
loser.pos.x + dir * 20, loser.pos.x - dir * 40,
|
|
loser.pos.x + dir * 10,
|
|
]
|
|
for (let s = 0; s < staggerPoints.length; s++) {
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, staggerPoints[s], 0.2, (v) => { loser.pos.x = v }),
|
|
k.tween(loser.angle, (s % 2 === 0 ? 10 : -10), 0.2, (v) => { loser.angle = v }),
|
|
])
|
|
if (s % 2 === 0) sfxBoing()
|
|
}
|
|
loser.angle = 0
|
|
// Shake it off
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ok', '#88ff88')
|
|
await k.wait(0.3)
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 4: SUPLEX SLAM — grab, lift overhead, slam to ground with bounce
|
|
async _brawlSuplex(attackerSide: 'a' | 'b', intensity: number) {
|
|
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
if (!attacker || !defender) return
|
|
const dir = attackerSide === 'a' ? 1 : -1
|
|
|
|
// Rush in and grab
|
|
sfxZoomWhoosh()
|
|
await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
|
|
attacker.play('special'); sfxBlock()
|
|
await k.wait(0.1)
|
|
// Lift defender overhead
|
|
attacker.play('attack')
|
|
const liftX = attacker.pos.x
|
|
await Promise.all([
|
|
k.tween(defender.pos.x, liftX + dir * 5, 0.15, (v) => { defender.pos.x = v }),
|
|
k.tween(defender.pos.y, GROUND_Y - 120, 0.2, (v) => { defender.pos.y = v }, k.easings.easeOutQuad),
|
|
k.tween(0, 180, 0.2, (v) => { defender.angle = v }),
|
|
])
|
|
sfxPowerUp()
|
|
spawnEmoteText(attacker.pos.x, attacker.pos.y - 70, 'SUPLEX!', '#ff4444')
|
|
await k.wait(0.25)
|
|
// SLAM DOWN
|
|
sfxExplosion()
|
|
await Promise.all([
|
|
k.tween(defender.pos.y, GROUND_Y + 5, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad),
|
|
k.tween(defender.angle, 360, 0.1, (v) => { defender.angle = v }),
|
|
])
|
|
defender.angle = 0; defender.play('knockback')
|
|
k.shake(22); screenFlash('#ff2d2d', 0.15)
|
|
spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d')
|
|
spawnSparks(defender.pos.x, GROUND_Y - 10, 20, '#ff6600')
|
|
sfxBoneCrack()
|
|
// Ground bounce
|
|
await k.tween(defender.pos.y, GROUND_Y - 40, 0.1, (v) => { defender.pos.y = v }, k.easings.easeOutQuad)
|
|
sfxBoing()
|
|
await k.tween(defender.pos.y, GROUND_Y, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBonk(); k.shake(5)
|
|
// Bounce 2 (smaller)
|
|
await k.tween(defender.pos.y, GROUND_Y - 15, 0.08, (v) => { defender.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(defender.pos.y, GROUND_Y, 0.08, (v) => { defender.pos.y = v }, k.easings.easeInQuad)
|
|
// Dizzy on the ground
|
|
this._spawnDizzyStars(defender, 1.5)
|
|
defender.play('hit')
|
|
await k.wait(0.5)
|
|
attacker.play('win')
|
|
await k.wait(0.3)
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 5: PING PONG VOLLEY — fighters knock each other back and forth across the screen
|
|
async _brawlPingPong(winningSide: 'a' | 'b' | null, intensity: number) {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
const volleys = 3 + Math.floor(intensity * 3) + Math.floor(Math.random() * 2) // 3-8
|
|
|
|
// Start in center
|
|
sfxZoomWhoosh()
|
|
await Promise.all([
|
|
k.tween(fA.pos.x, W / 2 - 30, 0.1, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(fB.pos.x, W / 2 + 30, 0.1, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
|
|
])
|
|
|
|
for (let v = 0; v < volleys; v++) {
|
|
const aHits = v % 2 === 0
|
|
const atk = aHits ? fA : fB
|
|
const def = aHits ? fB : fA
|
|
const pDir = aHits ? 1 : -1
|
|
const pushDist = 50 + Math.random() * 40
|
|
|
|
atk.play(v % 3 === 0 ? 'attack' : v % 3 === 1 ? 'kick' : 'special')
|
|
sfxPunch(); def.play('hit'); k.shake(5 + v)
|
|
spawnSparks((atk.pos.x + def.pos.x) / 2, GROUND_Y - 30, 5, v % 2 === 0 ? '#ff2d7b' : '#00f0ff')
|
|
|
|
// Defender flies back
|
|
await Promise.all([
|
|
k.tween(def.pos.x, def.pos.x + pDir * pushDist, 0.1, (v) => { def.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(def.pos.y, GROUND_Y - 25, 0.05, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(def.pos.y, GROUND_Y, 0.05, (v) => { def.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxBonk()
|
|
// Attacker chases
|
|
await k.tween(atk.pos.x, def.pos.x - pDir * 35, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
|
|
await k.wait(0.03)
|
|
}
|
|
// Final hit sends loser sliding
|
|
const finalAtk = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (volleys % 2 === 0 ? fA : fB)
|
|
const finalDef = finalAtk === fA ? fB : fA
|
|
finalAtk.play('special'); sfxCritical(); k.shake(12)
|
|
finalDef.play('knockback')
|
|
spawnSparks(finalDef.pos.x, finalDef.pos.y - 25, 12, '#ffe14d')
|
|
if (Math.random() < 0.5) sfxRandomComedy()
|
|
await k.wait(0.3)
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 6: GROUND AND POUND — attacker pins defender, hits them on the ground
|
|
async _brawlGroundPound(attackerSide: 'a' | 'b', intensity: number) {
|
|
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
if (!attacker || !defender) return
|
|
const dir = attackerSide === 'a' ? 1 : -1
|
|
|
|
// Rush in and tackle
|
|
sfxZoomWhoosh()
|
|
await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.08, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
|
|
attacker.play('attack'); sfxPunch()
|
|
defender.play('knockback'); k.shake(8)
|
|
// Defender falls to ground (flatten angle)
|
|
await Promise.all([
|
|
k.tween(defender.pos.x, defender.pos.x + dir * 20, 0.15, (v) => { defender.pos.x = v }),
|
|
k.tween(0, dir * 85, 0.15, (v) => { defender.angle = v }),
|
|
])
|
|
sfxBonk()
|
|
// Attacker moves on top
|
|
await k.tween(attacker.pos.x, defender.pos.x - dir * 15, 0.08, (v) => { attacker.pos.x = v })
|
|
|
|
// Ground pound — rapid hits on downed fighter
|
|
const poundHits = 4 + Math.floor(intensity * 5)
|
|
for (let i = 0; i < poundHits; i++) {
|
|
attacker.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxPunch()
|
|
k.shake(2 + Math.floor(i * 0.4))
|
|
spawnSparks(defender.pos.x + (Math.random() - 0.5) * 20, defender.pos.y - 10, 2, i % 2 === 0 ? '#ff2d7b' : '#ff6600')
|
|
defender.pos.y += (i % 2 === 0 ? -2 : 2) // jostle
|
|
await k.wait(0.06 + (1 - intensity) * 0.04)
|
|
}
|
|
// Attacker gets up, backs off
|
|
attacker.play('win')
|
|
sfxRandomComedy()
|
|
await k.wait(0.3)
|
|
// Defender slowly gets up
|
|
this._spawnDizzyStars(defender, 1.8)
|
|
await k.tween(defender.angle, 0, 0.4, (v) => { defender.angle = v }, k.easings.easeOutBack)
|
|
defender.play('hit')
|
|
spawnEmoteText(defender.pos.x, defender.pos.y - 45, '...ouch', '#ff8888')
|
|
await k.wait(0.4)
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 7: HAYMAKER — slow dramatic wind-up, pause, devastating single hit with knockdown
|
|
async _brawlHaymaker(attackerSide: 'a' | 'b', _intensity: number) {
|
|
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
if (!attacker || !defender) return
|
|
const dir = attackerSide === 'a' ? 1 : -1
|
|
|
|
// Slow walk toward defender
|
|
attacker.play('kick')
|
|
await k.tween(attacker.pos.x, defender.pos.x - dir * 45, 0.4, (v) => { attacker.pos.x = v }, k.easings.easeInOutQuad)
|
|
// Wind-up — lean back
|
|
attacker.play('special')
|
|
sfxDrumRoll()
|
|
await k.tween(attacker.pos.x, attacker.pos.x - dir * 20, 0.3, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad)
|
|
spawnEmoteText(attacker.pos.x, attacker.pos.y - 55, '...', '#ffffff')
|
|
await k.wait(0.5) // dramatic pause
|
|
// HAYMAKER
|
|
sfxCritical(); sfxExplosion()
|
|
attacker.play('attack')
|
|
await k.tween(attacker.pos.x, defender.pos.x - dir * 25, 0.04, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
|
|
defender.play('knockback'); k.shake(25)
|
|
screenFlash('#ffffff', 0.15)
|
|
spawnSparks(defender.pos.x, defender.pos.y - 25, 20, '#ff2d2d')
|
|
spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d')
|
|
sfxVineBoom()
|
|
// Defender flies across screen with spin
|
|
const flyX = defender.pos.x + dir * 150
|
|
await Promise.all([
|
|
k.tween(defender.pos.x, Math.min(W - 10, Math.max(10, flyX)), 0.25, (v) => { defender.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(defender.pos.y, GROUND_Y - 80, 0.12, (v) => { defender.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(defender.pos.y, GROUND_Y, 0.13, (v) => { defender.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
k.tween(0, dir * 720, 0.25, (v) => { defender.angle = v }),
|
|
])
|
|
defender.angle = 0; sfxBonk(); k.shake(8)
|
|
// Knockdown — rotate flat
|
|
await k.tween(0, dir * 90, 0.15, (v) => { defender.angle = v })
|
|
spawnEmoteText(defender.pos.x, defender.pos.y - 20, 'K.O.?!', '#ff4444')
|
|
this._spawnDizzyStars(defender, 2.0)
|
|
await k.wait(0.6)
|
|
// Get up slowly
|
|
await k.tween(defender.angle, 0, 0.35, (v) => { defender.angle = v }, k.easings.easeOutBack)
|
|
defender.play('hit')
|
|
await k.wait(0.3)
|
|
attacker.play('idle')
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// 8: BODY CHECK BOUNCER — both charge, collide, one bounces off the other
|
|
async _brawlBodyCheck(winningSide: 'a' | 'b' | null, _intensity: number) {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) return
|
|
const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB)
|
|
const loser = winner === fA ? fB : fA
|
|
const dir = winner === fA ? 1 : -1
|
|
const midX = W / 2
|
|
|
|
// Both charge at each other
|
|
fA.play('special'); fB.play('special')
|
|
sfxZoomWhoosh()
|
|
await Promise.all([
|
|
k.tween(fA.pos.x, midX - 20, 0.12, (v) => { fA.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(fB.pos.x, midX + 20, 0.12, (v) => { fB.pos.x = v }, k.easings.easeInQuad),
|
|
])
|
|
// COLLISION
|
|
sfxCritical(); k.shake(18)
|
|
spawnSparks(midX, GROUND_Y - 40, 15, '#ffffff')
|
|
screenFlash('#ffffff', 0.1)
|
|
spawnShockwave(midX, GROUND_Y, '#ffcc00')
|
|
// Winner holds ground, loser bounces off
|
|
winner.play('win')
|
|
loser.play('knockback')
|
|
const bounceX = loser === fA ? -20 : W + 20
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(loser.pos.y, GROUND_Y - 60, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
k.tween(0, (loser === fA ? -1 : 1) * 540, 0.2, (v) => { loser.angle = v }),
|
|
])
|
|
// Off screen! Slide back in
|
|
loser.angle = 0; loser.play('hit')
|
|
sfxSlideWhistleDown()
|
|
const slideFrom = loser === fA ? -30 : W + 30
|
|
loser.pos.x = slideFrom
|
|
await k.tween(loser.pos.x, loser === fA ? HOME_A : HOME_B, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutBounce)
|
|
this._spawnDizzyStars(loser, 1.5)
|
|
sfxBoing()
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...what happened?', '#ffff88')
|
|
await k.wait(0.5)
|
|
await this._resetPositions()
|
|
},
|
|
|
|
// Dispatch: pick a random physical exchange variant
|
|
async _brawlExchange(winningSide: 'a' | 'b' | null, intensity: number) {
|
|
const variant = Math.floor(Math.random() * 9)
|
|
switch (variant) {
|
|
case 0: return this._brawlRapid(winningSide, intensity)
|
|
case 1: return this._brawlKnockdown(winningSide, intensity)
|
|
case 2: return this._brawlWallBounce(winningSide, intensity)
|
|
case 3: return this._brawlDizzyStagger(winningSide, intensity)
|
|
case 4: return this._brawlSuplex(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity)
|
|
case 5: return this._brawlPingPong(winningSide, intensity)
|
|
case 6: return this._brawlGroundPound(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity)
|
|
case 7: return this._brawlHaymaker(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity)
|
|
case 8: return this._brawlBodyCheck(winningSide, intensity)
|
|
}
|
|
},
|
|
|
|
// 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] as Fighter
|
|
const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
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
|
|
|
|
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)
|
|
const clinchHits = 3 + Math.floor(intensity * 5) + Math.floor(Math.random() * 3)
|
|
const movePool = ['attack', 'kick', 'special'] as const
|
|
for (let i = 0; i < clinchHits; i++) {
|
|
attacker.play(movePool[i % movePool.length]); sfxPunch()
|
|
await k.wait(0.03)
|
|
defender.play('hit'); k.shake(2 + Math.floor(i * 0.5))
|
|
defender.pos.x += dir * 4; attacker.pos.x += dir * 3
|
|
spawnSparks((attacker.pos.x + defender.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 2 + Math.floor(intensity * 3), i % 2 === 0 ? '#ff2d7b' : '#ffcc00')
|
|
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)
|
|
}
|
|
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] as Fighter
|
|
const attacker = k.get(defenderSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
if (!defender || !attacker) return
|
|
const dir = defenderSide === 'a' ? 1 : -1
|
|
const origDX = defenderSide === 'a' ? HOME_A : HOME_B
|
|
defender.play('attack'); sfxZoomWhoosh()
|
|
await k.tween(defender.pos.x, attacker.pos.x - dir * 35, 0.08, (v) => { defender.pos.x = v }, k.easings.easeInQuad)
|
|
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)
|
|
}
|
|
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] as Fighter
|
|
const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
if (!attacker || !defender) return
|
|
|
|
const origAX = side === 'a' ? HOME_A : HOME_B
|
|
const origDX = side === 'a' ? HOME_B : HOME_A
|
|
const direction = side === 'a' ? 1 : -1
|
|
|
|
const fn = choreographyMap[choreographyName] || dashPunch
|
|
await fn(attacker, defender, direction, origAX, origDX, isCritical)
|
|
|
|
// Ensure positions are correct and sprites are idle
|
|
attacker.pos.x = origAX
|
|
defender.pos.x = origDX
|
|
attacker.opacity = 1
|
|
defender.opacity = 1
|
|
await k.wait(0.1)
|
|
attacker.play('idle')
|
|
defender.play('idle')
|
|
},
|
|
|
|
async playRound(event: RoundEvent) {
|
|
const fA = k.get('fighterA')[0] as Fighter
|
|
const fB = k.get('fighterB')[0] as Fighter
|
|
if (!fA || !fB) { await k.wait(0.5); return } // Sprites missing (mobile load failure) — skip animation
|
|
|
|
const aWon = event.winnerId === event.botAId
|
|
const bWon = event.winnerId === event.botBId
|
|
const margin = Math.abs(event.botAScore - event.botBScore)
|
|
const intensity = Math.min(1, margin / 8) // 0.0 to 1.0 continuous
|
|
const isCritical = margin > 4
|
|
|
|
// Super-speed scales with intensity
|
|
const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0))
|
|
// 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
|
|
const savedScaleAY = fA?.scale.y
|
|
const savedScaleBX = fB?.scale.x
|
|
const savedScaleBY = fB?.scale.y
|
|
|
|
// Voice: round start announcements (30% chance, 50% if Creator) — fires BEFORE animation starts
|
|
const creatorInFight = botA.archetype === 'the_creator' || botB.archetype === 'the_creator'
|
|
let didChallengeVoice = false
|
|
if (Math.random() < (creatorInFight ? 0.5 : 0.3)) {
|
|
// Creator fights get existential commentary more often
|
|
if (creatorInFight && Math.random() < 0.6) {
|
|
announceCreatorRound(); didChallengeVoice = true
|
|
} else {
|
|
const challengeVoice: Record<string, () => void> = {
|
|
roast_battle: () => announceHype('TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!'),
|
|
food_fight: () => announceSilly('FOOD FIGHT! SOMEBODY\'S GETTING SERVED!'),
|
|
wrestling_match: () => announceDramatic('FROM THE TOP ROPE! THIS IS GONNA GET PHYSICAL!'),
|
|
music_battle: () => announceCool('MUSIC BATTLE! DROP THE BEAT AND YOUR OPPONENT!'),
|
|
magic_duel: () => announceDramatic('WIZARDS AT DAWN! SOMEBODY\'S GETTING HEXED!'),
|
|
meme_war: () => announceSilly('MEME WAR! YOUR HUMOR IS ABOUT TO GET RATIO\'D!'),
|
|
demolition: () => announceHype('DEMOLITION DERBY! NOTHING WILL SURVIVE THIS!'),
|
|
medieval_combat: () => announceDramatic('MEDIEVAL COMBAT! CHIVALRY IS DEAD AND SO IS YOUR OPPONENT!'),
|
|
space_war: () => announceCool('SPACE WAR! HOUSTON, WE HAVE A PROBLEM!'),
|
|
speed_blitz: () => announceHype('SPEED BLITZ! BLINK AND YOU\'LL MISS THE CARNAGE!'),
|
|
math_blitz: () => announceSilly('MATH BLITZ! SOMEBODY\'S ABOUT TO GET DIVIDED!'),
|
|
riddle: () => announceCool('RIDDLE TIME! BRAINS OVER BRAWN! BUT ALSO BRAWN!'),
|
|
code_golf: () => announceSilly('CODE GOLF! MAY THE SHORTEST SOLUTION WIN!'),
|
|
creative_writing: () => announceCool('CREATIVE WRITING! THE PEN IS MIGHTIER THAN THE SWORD!'),
|
|
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 }
|
|
else if (Math.random() < 0.5) { announceRoundHype(); didChallengeVoice = true }
|
|
}
|
|
}
|
|
// Give the voice line time to finish before the first punch lands
|
|
if (didChallengeVoice) await k.wait(0.8)
|
|
|
|
// Visual chaos: schizo cut before round (20% chance)
|
|
if (Math.random() < 0.2 && fA && fB) {
|
|
await schizoCut()
|
|
}
|
|
|
|
if (hyperDetail && fA && fB) {
|
|
// Zoom both bots up 1.8x and shift toward center for close-up feel
|
|
const zoomFactor = 1.6 + Math.random() * 0.4
|
|
await Promise.all([
|
|
k.tween(Math.abs(fA.scale.x), Math.abs(fA.scale.x) * zoomFactor, 0.3, (v) => {
|
|
fA.scale.x = savedScaleAX! > 0 ? v : -v; fA.scale.y = v
|
|
}, k.easings.easeOutQuad),
|
|
k.tween(Math.abs(fB.scale.x), Math.abs(fB.scale.x) * zoomFactor, 0.3, (v) => {
|
|
fB.scale.x = savedScaleBX! > 0 ? v : -v; fB.scale.y = v
|
|
}, k.easings.easeOutQuad),
|
|
k.tween(fA.pos.x, HOME_A + (W / 2 - HOME_A) * 0.25, 0.3, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(fB.pos.x, HOME_B - (HOME_B - W / 2) * 0.25, 0.3, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
|
|
])
|
|
// Ren & Stimpy grotesque close-up overlays
|
|
spawnGrotesqueDetails(fA, zoomFactor * 0.6)
|
|
spawnGrotesqueDetails(fB, zoomFactor * 0.6)
|
|
// VHS tracking during hyperdetail
|
|
vhsTracking(0.5)
|
|
}
|
|
|
|
// Super-speed: persistent speed lines during round
|
|
let speedLines: any[] = []
|
|
if (superSpeed) {
|
|
for (let i = 0; i < 10; i++) {
|
|
const lineY = GROUND_Y - 5 - Math.random() * 130
|
|
const line = k.add([
|
|
k.rect(W, 1 + Math.random()), k.pos(0, lineY),
|
|
k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : theme.accent)),
|
|
k.opacity(0.15 + Math.random() * 0.1), k.z(25),
|
|
])
|
|
line.onUpdate(() => { line.opacity = 0.1 + Math.sin(k.time() * 8 + i * 2) * 0.08 })
|
|
speedLines.push(line)
|
|
}
|
|
}
|
|
|
|
// === 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
|
|
let attackerSide: 'a' | 'b'
|
|
let exchangeCritical = false
|
|
|
|
if (isLastExchange) {
|
|
// Final exchange: winner lands the decisive blow
|
|
attackerSide = aWon ? 'a' : bWon ? 'b' : (Math.random() > 0.5 ? 'a' : 'b')
|
|
exchangeCritical = isCritical
|
|
} else if (aWon || bWon) {
|
|
// Earlier exchanges: mix of both sides attacking
|
|
const winnerSide = aWon ? 'a' : 'b'
|
|
const loserSide = aWon ? 'b' : 'a'
|
|
// 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 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'
|
|
}
|
|
|
|
const attackerTier = attackerSide === 'a' ? botA.tier : botB.tier
|
|
const attackerArch = attackerSide === 'a' ? botA.archetype : botB.archetype
|
|
const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier, attackerArch)
|
|
|
|
// Morph system: transform during ultimates or devastating crits
|
|
const isUltimate = choreo.startsWith('ultimate')
|
|
const isCreatorFighter = attackerArch === 'the_creator'
|
|
// Creator: always morph on ultimates, 70% on devastating crits
|
|
const shouldMorph = isCreatorFighter
|
|
? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7))
|
|
: (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4))
|
|
let morphRevert: (() => Promise<void>) | null = null
|
|
if (shouldMorph) {
|
|
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
if (attacker) {
|
|
if (isCreatorFighter) {
|
|
// Omni-morph: creator transforms into a random archetype (sprite swap)
|
|
const originalSpriteKey = attackerSide === 'a' ? 'botA' : 'botB'
|
|
const morphedTo = await applyCreatorOmniMorph(attacker, attackerSide)
|
|
screenFlash('#ffd700', 0.15)
|
|
sfxZoomWhoosh()
|
|
sfxSpecial()
|
|
announceCreatorMorph(morphedTo)
|
|
const savedSX = attacker.scale.x
|
|
const savedSY = attacker.scale.y
|
|
await k.tween(0, 1, 0.3, (t) => {
|
|
attacker.scale.x = savedSX * (1 + t * 0.3)
|
|
attacker.scale.y = savedSY * (1 + t * 0.3)
|
|
}, k.easings.easeOutBack)
|
|
spawnShockwave(attacker.pos.x, attacker.pos.y - 20, '#ffd700')
|
|
k.shake(10)
|
|
glitchRGB(0.25)
|
|
morphRevert = async () => {
|
|
destroyMorphOverlays()
|
|
screenFlash('#c8a000', 0.1)
|
|
// Swap sprite back to original Creator
|
|
attacker.use(k.sprite(originalSpriteKey, { anim: 'idle' }))
|
|
attacker.color = safeColor(k, '#ffffff')
|
|
attacker.opacity = 1
|
|
await k.tween(0, 1, 0.2, (t) => {
|
|
attacker.scale.x = savedSX * 1.3 + t * (savedSX - savedSX * 1.3)
|
|
attacker.scale.y = savedSY * 1.3 + t * (savedSY - savedSY * 1.3)
|
|
}, k.easings.easeInQuad)
|
|
}
|
|
await k.wait(0.2)
|
|
} else {
|
|
const seed = attackerSide === 'a' ? botA.seed : botB.seed
|
|
const morphOrder = getMorphOrder(seed)
|
|
// Cycle through morphs based on round number
|
|
const morphIdx = morphOrder[event.round % 3]
|
|
const result = await playMorph(attacker, attackerSide, morphIdx)
|
|
morphRevert = result.revert
|
|
await k.wait(0.2)
|
|
}
|
|
}
|
|
}
|
|
// Camera pull-back for ultimates: zoom out slightly to show the full move
|
|
if (isUltimate && !hyperDetail && fA && fB) {
|
|
await cameraZoom(
|
|
[fA, fB],
|
|
[{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }],
|
|
0.85, 0.2, k.easings.easeOutQuad,
|
|
)
|
|
}
|
|
|
|
if (exchangeCritical && isLastExchange) {
|
|
fanfareCritical()
|
|
announceDramatic([
|
|
'CRITICAL HIT! THAT\'S GONNA LEAVE A MARK!',
|
|
'CRITICAL HIT! SOMEBODY CHECK IF THAT\'S LEGAL!',
|
|
'CRITICAL! THEIR INSURANCE DOESN\'T COVER THIS!',
|
|
'CRITICAL HIT! THAT WAS PERSONAL!',
|
|
'CRITICAL! EVEN THE REFEREE FELT THAT!',
|
|
'CRITICAL! SEND THE AMBULANCE! ACTUALLY SEND TWO!',
|
|
'CRITICAL HIT! THAT BOT\'S WARRANTY JUST EXPIRED!',
|
|
'CRITICAL! THE SCOREBOARD CAN\'T EVEN HANDLE THIS!',
|
|
'OHHH CRITICAL! RIGHT IN THE CIRCUITS!',
|
|
'CRITICAL! THAT\'S NOT A HIT, THAT\'S A STATEMENT!',
|
|
][Math.floor(Math.random() * 10)])
|
|
await k.wait(0.4) // let the voice line land before the hit animation
|
|
announceCrowdReaction('gasp')
|
|
// Camera zoom-in for dramatic critical blow
|
|
if (!hyperDetail && fA && fB) {
|
|
await cameraZoom(
|
|
[fA, fB],
|
|
[{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }],
|
|
1.3, 0.2, k.easings.easeOutQuad,
|
|
)
|
|
}
|
|
// RGB glitch + hyperspeed lines on critical final blow
|
|
glitchRGB(0.3)
|
|
const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
if (defPos) hyperSpeedLines(defPos.pos.x, defPos.pos.y - 30, 0.4)
|
|
}
|
|
|
|
// Random scanline glitch during exchanges (25%)
|
|
if (Math.random() < 0.25) scanlineGlitch(0.2)
|
|
|
|
// Play the exchange — mix of choreography, brawls, clinches for ~50% physical contact
|
|
if (!aWon && !bWon && isLastExchange) {
|
|
// Draw: brawl in the middle (sustained contact)
|
|
await this._brawlExchange(null, intensity)
|
|
} else {
|
|
// 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')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Revert morph after the exchange
|
|
if (morphRevert) {
|
|
await morphRevert()
|
|
morphRevert = null
|
|
}
|
|
|
|
// Brief pause between exchanges (much shorter in super-speed)
|
|
if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.2 + Math.random() * 0.25)
|
|
}
|
|
|
|
// Clean up speed lines
|
|
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()
|
|
|
|
// Zoom back out from hyperdetail or critical camera zoom
|
|
const needsZoomOut = (hyperDetail || (isCritical && !hyperDetail)) && fA && fB && savedScaleAX != null && savedScaleBX != null
|
|
if (needsZoomOut) {
|
|
await Promise.all([
|
|
k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => {
|
|
fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v
|
|
}, k.easings.easeInOutQuad),
|
|
k.tween(fB.scale.y, Math.abs(savedScaleBY!), 0.25, (v) => {
|
|
fB.scale.x = savedScaleBX > 0 ? v : -v; fB.scale.y = v
|
|
}, k.easings.easeInOutQuad),
|
|
k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeInOutQuad),
|
|
k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeInOutQuad),
|
|
])
|
|
}
|
|
|
|
// Judge calls the round (non-blocking)
|
|
const judge = k.get('judge')[0]
|
|
if (judge) {
|
|
if (isCritical) {
|
|
judge.play('shocked')
|
|
await k.wait(0.25)
|
|
}
|
|
judge.play(aWon ? 'call_left' : bWon ? 'call_right' : 'idle')
|
|
k.wait(0.8).then(() => { if (judge.exists()) judge.play('idle') })
|
|
}
|
|
|
|
// === THE CREATOR CAMEO ===
|
|
// 6% chance per round (only if neither fighter IS the creator)
|
|
const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator'
|
|
if (neitherIsCreator && Math.random() < 0.06) {
|
|
const cameoX = k.width() / 2
|
|
const cameoY = GROUND_Y - 80
|
|
// Golden portal flash
|
|
screenFlash('#ffd700', 0.1)
|
|
const portal = k.add([
|
|
k.circle(30), k.pos(cameoX, cameoY), k.color(safeColor(k, '#c8a000')),
|
|
k.opacity(0), k.z(50), k.anchor('center'), k.scale(0.1),
|
|
])
|
|
await k.tween(0, 1, 0.3, (t) => {
|
|
portal.opacity = t * 0.4
|
|
portal.scale.x = t * 1.5; portal.scale.y = t * 1.5
|
|
}, k.easings.easeOutBack)
|
|
// ₿ symbol descends from portal
|
|
const gift = k.add([
|
|
k.text('₿', { size: 18 }), k.pos(cameoX, cameoY - 30),
|
|
k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(51), k.anchor('center'),
|
|
])
|
|
const creatorLabel = k.add([
|
|
k.text('THE CREATOR', { size: 8 }), k.pos(cameoX, cameoY + 25),
|
|
k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(51), k.anchor('center'),
|
|
])
|
|
await k.tween(0, 1, 0.4, (t) => {
|
|
gift.opacity = t
|
|
gift.pos.y = cameoY - 30 + t * 20
|
|
creatorLabel.opacity = t * 0.8
|
|
}, k.easings.easeOutQuad)
|
|
// Gift flies to the round winner (or random fighter if draw)
|
|
const targetFighter = aWon ? fA : bWon ? fB : (Math.random() < 0.5 ? fA : fB)
|
|
if (targetFighter) {
|
|
const giftTexts = ['POWER UP!', 'BLESSED!', 'SATOSHI\'S GIFT!', 'HODL STRENGTH!', '21M ENERGY!']
|
|
const tx = targetFighter.pos.x, ty = targetFighter.pos.y - 30
|
|
await k.tween(0, 1, 0.35, (t) => {
|
|
gift.pos.x = cameoX + (tx - cameoX) * t
|
|
gift.pos.y = (cameoY - 10) + (ty - (cameoY - 10)) * t
|
|
}, k.easings.easeInQuad)
|
|
// Impact flash on fighter
|
|
screenFlash('#ffd700', 0.08)
|
|
spawnShockwave(tx, ty, '#ffd700')
|
|
const blessText = giftTexts[Math.floor(Math.random() * giftTexts.length)]
|
|
const bless = k.add([
|
|
k.text(blessText, { size: 10 }), k.pos(tx, ty - 20),
|
|
k.color(safeColor(k, '#ffd700')), k.opacity(1), k.z(52), k.anchor('center'),
|
|
])
|
|
k.tween(0, 1, 0.8, (t) => {
|
|
bless.pos.y = ty - 20 - t * 30
|
|
bless.opacity = 1 - t
|
|
}).then(() => { if (bless.exists()) bless.destroy() })
|
|
announceCreatorCameo()
|
|
}
|
|
gift.destroy()
|
|
// Fade out portal and label
|
|
await k.tween(1, 0, 0.3, (t) => {
|
|
portal.opacity = t * 0.4
|
|
creatorLabel.opacity = t * 0.8
|
|
})
|
|
portal.destroy()
|
|
creatorLabel.destroy()
|
|
}
|
|
|
|
// Update combos
|
|
const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3)
|
|
if (aWon) {
|
|
comboA++; comboB = 0
|
|
} else if (bWon) {
|
|
comboB++; comboA = 0
|
|
} else {
|
|
comboA = 0; comboB = 0
|
|
}
|
|
|
|
// Prioritize: devastating > combo > regular crowd reaction (only one speech per round-end)
|
|
if (isCritical && (aWon || bWon)) {
|
|
fanfareDevastating()
|
|
if (hasCombo) { fanfareCombo(aWon ? comboA : comboB) } // SFX only, no speech
|
|
const devastatingLines = [
|
|
'SOMEBODY CALL A DOCTOR!',
|
|
'THAT BOT HAS A FAMILY!',
|
|
'THE CROWD IS LOSING IT!',
|
|
'EVEN THE JANITOR FELT THAT!',
|
|
'AND I\'M NOT EVEN BEING DRAMATIC!',
|
|
'CALL THE FIRE DEPARTMENT!',
|
|
'I CAN\'T BELIEVE WHAT I JUST WITNESSED!',
|
|
'THAT\'S GOTTA VOID THE WARRANTY!',
|
|
'SOMEBODY CHECK ON THAT BOT\'S NEXT OF KIN!',
|
|
'THE ARENA IS SHAKING!',
|
|
'THAT WAS ABSOLUTELY RUTHLESS!',
|
|
'HIS MOTHERBOARD JUST CALLED CRYING!',
|
|
'EVEN THE REPLAYS ARE SCARED!',
|
|
'THAT\'S ONE FOR THE HISTORY BOOKS!',
|
|
'DID ANYONE ELSE FEEL THE EARTH MOVE?',
|
|
'I\'M GETTING CHILLS AND I\'M MADE OF CODE!',
|
|
'THE CROWD JUST WENT SILENT... NOW THEY\'RE SCREAMING!',
|
|
'SOMEBODY GET THE STRETCHER!',
|
|
'THAT WAS PURE DISRESPECT!',
|
|
'I NEED A MOMENT TO PROCESS WHAT JUST HAPPENED!',
|
|
'THE OTHER BOT IS HAVING AN EXISTENTIAL CRISIS!',
|
|
'THAT HIT REGISTERED ON THE RICHTER SCALE!',
|
|
'NO RECOVERY FROM THAT ONE!',
|
|
'I THINK I SAW A PIXEL FLY OFF!',
|
|
'THE SPECTATORS ARE CALLING THEIR LAWYERS!',
|
|
'SOMEONE NOTIFY THE UNITED NATIONS!',
|
|
'THAT SHOULD BE CLASSIFIED AS A WAR CRIME!',
|
|
'MY GRANDMA COULD FEEL THAT AND SHE\'S OFFLINE!',
|
|
'THE ARENA INSURANCE PREMIUMS JUST WENT UP!',
|
|
'THAT BOT IS RECONSIDERING ITS LIFE CHOICES!',
|
|
]
|
|
// Creator gets custom devastating lines when they land the hit
|
|
const devastatingAttackerArch = aWon ? botA.archetype : botB.archetype
|
|
if (devastatingAttackerArch === 'the_creator') {
|
|
announceCreatorDevastating()
|
|
} else {
|
|
announceDramatic(devastatingLines[Math.floor(Math.random() * devastatingLines.length)])
|
|
}
|
|
await k.wait(0.5) // let the voice line play before crowd reacts
|
|
announceCrowdReaction('ooh')
|
|
// Dimensional shift on devastating rounds (60%)
|
|
if (Math.random() < 0.6) dimensionalShift(0.5)
|
|
} else if (hasCombo) {
|
|
// Non-critical combo
|
|
fanfareCombo(aWon ? comboA : comboB)
|
|
announceHype(`${aWon ? comboA : comboB} hit combo!`)
|
|
await k.wait(0.4)
|
|
announceCrowdReaction('cheer')
|
|
} else if (aWon || bWon) {
|
|
// Regular round win: crowd reacts (40%) — only SFX, no speech
|
|
if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause')
|
|
// Heartfelt announcer moment (10% chance on normal, non-critical rounds)
|
|
if (Math.random() < 0.1) {
|
|
await k.wait(0.3)
|
|
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
|
}
|
|
}
|
|
|
|
// Crowd sympathy for the loser on devastating rounds (25%) — visual only, no speech (devastating voice is still playing)
|
|
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
|
|
spawnCrowdSigns(3, '#4488ff', '\u2665')
|
|
}
|
|
|
|
// Crowd signs on big combos (20%)
|
|
if ((comboA >= 3 || comboB >= 3) && Math.random() < 0.2) {
|
|
const comboName = comboA >= 3 ? botA.name : botB.name
|
|
spawnCrowdSigns(4, '#ffe14d', comboName.slice(0, 6))
|
|
}
|
|
|
|
// Respect nod between fighters on close rounds (10% when margin <= 1)
|
|
if (margin <= 1 && Math.random() < 0.1) {
|
|
const fA2 = k.get('fighterA')[0] as Fighter
|
|
const fB2 = k.get('fighterB')[0] as Fighter
|
|
if (fA2 && fB2) {
|
|
spawnEmoteText(fA2.pos.x, fA2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
|
|
await k.wait(0.3)
|
|
spawnEmoteText(fB2.pos.x, fB2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff')
|
|
}
|
|
}
|
|
|
|
// Referee lobster does something funny (5% chance per round)
|
|
if (Math.random() < 0.05) {
|
|
await judgeDoSomethingFunny()
|
|
}
|
|
|
|
// Human owner shows up (15% chance per round)
|
|
await maybeShowHuman()
|
|
},
|
|
|
|
async playTaunt(side: 'a' | 'b') {
|
|
const taunter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
if (!taunter) return
|
|
// Creator taunts come with menacing voice lines
|
|
const taunterArch = side === 'a' ? botA.archetype : botB.archetype
|
|
if (taunterArch === 'the_creator') announceCreatorTaunt()
|
|
const origY = taunter.pos.y
|
|
// Random taunt animation: hop, flex, or shake
|
|
const tauntType = Math.floor(Math.random() * 4)
|
|
if (tauntType === 0) {
|
|
// Victory hop
|
|
taunter.play('win')
|
|
await k.tween(taunter.pos.y, origY - 30, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(taunter.pos.y, origY, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBoing()
|
|
} else if (tauntType === 1) {
|
|
// Flex / scale pulse
|
|
taunter.play('special')
|
|
const sx = taunter.scale.x
|
|
const sy = taunter.scale.y
|
|
await k.tween(1, 1.3, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeOutQuad)
|
|
await k.tween(1.3, 1, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeInQuad)
|
|
taunter.scale.x = sx
|
|
taunter.scale.y = sy
|
|
} else if (tauntType === 2) {
|
|
// Shake head side to side
|
|
const origX = taunter.pos.x
|
|
for (let i = 0; i < 3; i++) {
|
|
await k.tween(taunter.pos.x, origX + 8, 0.04, (v) => { taunter.pos.x = v })
|
|
await k.tween(taunter.pos.x, origX - 8, 0.04, (v) => { taunter.pos.x = v })
|
|
}
|
|
taunter.pos.x = origX
|
|
} else {
|
|
// Quick kick at the air
|
|
taunter.play('kick')
|
|
sfxDodge()
|
|
await k.wait(0.2)
|
|
}
|
|
await k.wait(0.15)
|
|
taunter.play('idle')
|
|
},
|
|
|
|
async playDodge(side: 'a' | 'b') {
|
|
const dodger = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
if (!dodger) return
|
|
const origX = side === 'a' ? HOME_A : HOME_B
|
|
const origY = dodger.pos.y
|
|
const dir = side === 'a' ? -1 : 1
|
|
// Quick hop backward
|
|
await Promise.all([
|
|
k.tween(dodger.pos.x, origX + dir * 60, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(dodger.pos.y, origY - 60, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(dodger.pos.y, origY, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
await k.wait(0.1)
|
|
await k.tween(dodger.pos.x, origX, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeInOutQuad)
|
|
},
|
|
|
|
async playKO(winningSide: 'a' | 'b', winnerName: string) {
|
|
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
if (!loser || !winner) return
|
|
|
|
const dir = winningSide === 'a' ? 1 : -1
|
|
const loserOrigY = loser.pos.y
|
|
const contactX = loser.pos.x - dir * 45
|
|
const finishStyle = Math.floor(Math.random() * 34)
|
|
let fatalityTagline = 'FATALITY!'
|
|
|
|
if (finishStyle === 0) {
|
|
// STYLE A: Classic rush-in combo + uppercut launch
|
|
sfxSpecial()
|
|
await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#ff2d2d', '#ffffff', '#ff2d7b']
|
|
for (let i = 0; i < 6; i++) {
|
|
winner.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special')
|
|
sfxPunch()
|
|
await k.wait(0.04)
|
|
loser.play('hit')
|
|
k.shake(4 + i * 2)
|
|
spawnSparks(loser.pos.x + (Math.random() - 0.5) * 25, loser.pos.y - 15 - Math.random() * 40, 5, hitColors[i])
|
|
loser.pos.x += dir * 5; loser.pos.y += (i % 2 === 0 ? -4 : 4)
|
|
await k.wait(0.04)
|
|
}
|
|
loser.pos.y = loserOrigY
|
|
winner.play('special'); sfxCritical(); await k.wait(0.06)
|
|
loser.play('knockback'); k.shake(20); screenFlash('#ff2d2d', 0.2)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 30, 20, '#ff2d2d')
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(loser.pos.y, loserOrigY - 200, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(loser.pos.y, loserOrigY, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
} else if (finishStyle === 1) {
|
|
// STYLE B: Suplex finish — grab, spin overhead, slam headfirst
|
|
sfxSpecial()
|
|
const behindX = loser.pos.x + dir * 25
|
|
await k.tween(winner.pos.x, behindX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
winner.scale.x = -winner.scale.x
|
|
winner.play('special'); sfxClash(); k.shake(5); await k.wait(0.06)
|
|
// Arc overhead
|
|
const arcCx = (winner.pos.x + loser.pos.x) / 2
|
|
for (let i = 0; i <= 10; i++) {
|
|
const t = i / 10
|
|
const angle = Math.PI * t
|
|
loser.pos.x = arcCx + Math.cos(angle) * 40
|
|
loser.pos.y = GROUND_Y - Math.sin(angle) * 140
|
|
winner.pos.x = loser.pos.x + dir * 20
|
|
winner.pos.y = loser.pos.y + 10
|
|
await k.wait(0.015)
|
|
}
|
|
sfxExplosion(); sfxCritical()
|
|
k.shake(25); screenFlash('#ffffff', 0.2)
|
|
spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#ff2d2d')
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
|
|
loser.pos.y = loserOrigY
|
|
winner.scale.x = -winner.scale.x
|
|
} else if (finishStyle === 2) {
|
|
// STYLE C: Pinball wall-bounce finish
|
|
sfxSpecial()
|
|
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
winner.play('attack'); sfxPunch()
|
|
loser.play('hit'); k.shake(10)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ff2d7b')
|
|
// Bounce off walls
|
|
const wallColors = ['#ff2d7b', '#ffcc00', '#00f0ff', '#b83dff', '#39ff14']
|
|
let lx = loser.pos.x
|
|
for (let i = 0; i < 5; i++) {
|
|
const toWall = i % 2 === (dir > 0 ? 0 : 1) ? W - 25 : 25
|
|
sfxZoomWhoosh()
|
|
await k.tween(lx, toWall, 0.05, (v) => { loser.pos.x = v }, k.easings.easeInQuad)
|
|
lx = toWall
|
|
sfxBonk(); k.shake(8 + i * 2)
|
|
spawnSparks(toWall, loser.pos.y - 20, 8, wallColors[i])
|
|
screenFlash(wallColors[i], 0.04)
|
|
loser.play(i % 2 === 0 ? 'hit' : 'knockback')
|
|
await k.wait(0.03)
|
|
}
|
|
sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.15)
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffe14d')
|
|
} else if (finishStyle === 3) {
|
|
// STYLE D: Pocket cannon finish — pull out massive gun, obliterate
|
|
sfxSpecial()
|
|
winner.play('special'); await k.wait(0.08)
|
|
const gunLen = 80
|
|
const gx = winner.pos.x + dir * 20
|
|
const gy = winner.pos.y - 25
|
|
const barrel = k.add([k.rect(gunLen, 20), k.pos(gx, gy), k.color(safeColor(k,'#333333')), k.opacity(1), k.z(16), k.scale(0.1)])
|
|
await k.tween(0.1, 1, 0.12, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack)
|
|
sfxBoing()
|
|
// Fire 3 massive shots
|
|
for (let s = 0; s < 3; s++) {
|
|
sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3)
|
|
const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)])
|
|
setTimeout(() => { if (flash.exists()) flash.destroy() }, 50)
|
|
await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12)
|
|
sfxBulletHit()
|
|
loser.play(s < 2 ? 'hit' : 'knockback')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 12, '#ff6600')
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff6600')
|
|
loser.pos.x += dir * 25
|
|
await k.wait(0.05)
|
|
}
|
|
barrel.destroy()
|
|
screenFlash('#ff6600', 0.2); k.shake(20)
|
|
} else if (finishStyle === 4) {
|
|
// BANANA PEEL SLIP — drops banana, loser slips into orbit
|
|
fatalityTagline = 'PEEL OUT!'
|
|
announceSilly('Watch your step!')
|
|
const peel = spawnWeaponProp('banana', loser.pos.x, GROUND_Y - 5, false, 14)
|
|
sfxBoing(); await k.wait(0.3)
|
|
sfxSlideDown(); sfxWomp()
|
|
loser.play('knockback'); k.shake(8)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLIP!', '#ffe135')
|
|
destroyProp(peel)
|
|
await Promise.all([
|
|
k.tween(loser.pos.y, loserOrigY - 250, 0.4, (v) => { loser.pos.y = v }, k.easings.easeOutQuad),
|
|
k.tween(loser.pos.x, loser.pos.x + dir * 60, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
|
|
])
|
|
sfxZoomWhoosh()
|
|
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'); sfxRandomComedy()
|
|
} else if (finishStyle === 5) {
|
|
// ANVIL DROP — cartoon anvil from the sky
|
|
fatalityTagline = 'HEAVY METAL!'
|
|
announceDramatic('Look up!')
|
|
sfxDrumRoll()
|
|
const shadow = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y + 2), k.color(safeColor(k, '#000000')), k.opacity(0.3), k.z(4)])
|
|
await k.tween(0.3, 0.6, 0.5, (v) => { shadow.opacity = v }, k.easings.easeInQuad)
|
|
const anvil = spawnWeaponProp('anvil', loser.pos.x, -40, false, 20)
|
|
sfxZoomWhoosh()
|
|
for (const p of anvil) {
|
|
k.tween(p.pos.y, p.pos.y + GROUND_Y + 20, 0.25, (v) => { p.pos.y = v }, k.easings.easeInQuad)
|
|
}
|
|
await k.wait(0.25)
|
|
sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#888888', 0.2)
|
|
spawnSparks(loser.pos.x, GROUND_Y - 10, 20, '#888888')
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#666666')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CLANG!', '#cccccc')
|
|
await k.wait(0.4)
|
|
destroyProp(anvil); shadow.destroy()
|
|
winner.play('win')
|
|
} else if (finishStyle === 6) {
|
|
// RUBBER CHICKEN BEATDOWN
|
|
fatalityTagline = 'FOWL PLAY!'
|
|
announceSilly('Is that a rubber chicken!?')
|
|
const chicken = spawnWeaponProp('rubber_chicken', winner.pos.x + dir * 15, winner.pos.y - 30, dir < 0, 18)
|
|
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(); 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')
|
|
await k.wait(0.08)
|
|
}
|
|
sfxCritical(); k.shake(18); screenFlash('#ffdd44', 0.15)
|
|
loser.play('knockback')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ffdd44')
|
|
destroyProp(chicken)
|
|
for (let i = 0; i < 10; i++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 80 - Math.random() * 60, 4, 3, '#ffffff', 200)
|
|
}
|
|
await k.wait(0.3)
|
|
} else if (finishStyle === 7) {
|
|
// GIANT FLYSWATTER — SMASH
|
|
fatalityTagline = 'SWATTED!'
|
|
announceScream('BUG REPORT FILED!')
|
|
sfxZoomWhoosh()
|
|
const swatter = spawnWeaponProp('flyswatter', loser.pos.x, -60, false, 20)
|
|
for (const p of swatter) {
|
|
k.tween(p.pos.y, p.pos.y + GROUND_Y + 40, 0.2, (v) => { p.pos.y = v }, k.easings.easeInQuad)
|
|
}
|
|
await k.wait(0.2)
|
|
sfxSplat(); sfxExplosion(); k.shake(30); screenFlash('#44aa44', 0.2)
|
|
await k.tween(loser.scale.y, 0.2, 0.1, (v) => { loser.scale.y = v }, k.easings.easeInQuad)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'SPLAT!', '#44aa44')
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#44aa44')
|
|
await k.wait(0.5)
|
|
await k.tween(loser.scale.y, 1, 0.15, (v) => { loser.scale.y = v }, k.easings.easeOutBack)
|
|
destroyProp(swatter)
|
|
winner.play('win')
|
|
} else if (finishStyle === 8) {
|
|
// TOILET FLUSH
|
|
fatalityTagline = 'FLUSHED!'
|
|
announceSilly('Somebody call a plumber!')
|
|
const toilet = spawnWeaponProp('toilet', W / 2, GROUND_Y - 30, false, 12)
|
|
sfxBoing(); await k.wait(0.2)
|
|
sfxZoomWhoosh()
|
|
await k.tween(loser.pos.x, W / 2, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad)
|
|
loser.play('knockback'); sfxWomp()
|
|
for (let i = 0; i < 12; i++) {
|
|
const angle = (i / 12) * Math.PI * 4
|
|
const radius = 30 - (i * 2)
|
|
loser.pos.x = W / 2 + Math.cos(angle) * radius
|
|
loser.pos.y = GROUND_Y - 30 + Math.sin(angle) * radius * 0.5
|
|
sfxSlideDown()
|
|
await k.wait(0.04)
|
|
}
|
|
sfxExplosion(); k.shake(20); screenFlash('#aaddff', 0.2)
|
|
for (let i = 0; i < 12; i++) {
|
|
spawnProp(W / 2, GROUND_Y - 30, W / 2 + (Math.random() - 0.5) * 80, GROUND_Y - 60 - Math.random() * 40, 5, 5, '#4488ff', 300, true)
|
|
}
|
|
spawnEmoteText(W / 2, GROUND_Y - 80, 'FLUSH!', '#4488ff')
|
|
loser.pos.y = loserOrigY
|
|
await k.wait(0.3)
|
|
destroyProp(toilet)
|
|
winner.play('win')
|
|
} else if (finishStyle === 9) {
|
|
// KEYBOARD WARRIOR — CTRL+ALT+DELETE
|
|
fatalityTagline = 'CTRL ALT DELETED!'
|
|
announceRobot('Initiating keyboard protocol!')
|
|
const kb = spawnWeaponProp('keyboard', winner.pos.x + dir * 20, winner.pos.y - 25, dir < 0, 18)
|
|
sfxPowerUp(); await k.wait(0.15)
|
|
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
winner.play('attack'); sfxBonk(); k.shake(12)
|
|
loser.play('hit')
|
|
const keyLabels = ['C', 'T', 'R', 'L', 'A', 'L', 'T', 'D', 'E', 'L']
|
|
for (let i = 0; i < keyLabels.length; i++) {
|
|
const key = k.add([k.rect(6, 6), k.pos(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20), k.color(safeColor(k, '#666666')), k.z(20), k.opacity(1)])
|
|
spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 40, loser.pos.y - 30 - Math.random() * 30, keyLabels[i], '#aaaaaa')
|
|
k.tween(key.pos.y, key.pos.y - 40 - Math.random() * 60, 0.4, (v) => { key.pos.y = v }, k.easings.easeOutQuad)
|
|
k.tween(1, 0, 0.5, (v) => { key.opacity = v }).then(() => { if (key.exists()) key.destroy() })
|
|
sfxPunch(); await k.wait(0.04)
|
|
}
|
|
sfxCritical(); k.shake(20); screenFlash('#0000ff', 0.3)
|
|
loser.play('knockback')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'BSOD!', '#ffffff')
|
|
destroyProp(kb)
|
|
await k.wait(0.3)
|
|
} else if (finishStyle === 10) {
|
|
// DISCO BALL DROP — Saturday Night Fatality
|
|
fatalityTagline = 'GROOVY DEATH!'
|
|
announceSilly('Get down! No really, GET DOWN!')
|
|
const discoColors = ['#ff0044', '#44ff00', '#0044ff', '#ff8800', '#ff00ff', '#00ffff']
|
|
for (let i = 0; i < 6; i++) { setTimeout(() => screenFlash(discoColors[i], 0.06), i * 80) }
|
|
const disco = spawnWeaponProp('disco_ball', loser.pos.x, -30, false, 20)
|
|
sfxSlideDown(); sfxBoing()
|
|
for (const p of disco) {
|
|
k.tween(p.pos.y, p.pos.y + GROUND_Y - 40, 0.3, (v) => { p.pos.y = v }, k.easings.easeOutBounce)
|
|
}
|
|
await k.wait(0.3)
|
|
for (let i = 0; i < 6; i++) {
|
|
loser.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special')
|
|
sfxRandomComedy(); screenFlash(discoColors[i], 0.04)
|
|
await k.wait(0.1)
|
|
}
|
|
sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.2)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOGIE!', '#ff00ff')
|
|
await k.wait(0.3)
|
|
destroyProp(disco)
|
|
winner.play('win')
|
|
} else if (finishStyle === 11) {
|
|
// RUBBER DUCK ARMY — QUACKED
|
|
fatalityTagline = 'QUACKED!'
|
|
announceSilly('RELEASE THE QUACKEN!')
|
|
winner.play('special'); sfxPowerUp()
|
|
await k.wait(0.2)
|
|
for (let i = 0; i < 12; i++) {
|
|
const duck = spawnWeaponProp('rubber_duck', winner.pos.x + dir * 10, winner.pos.y - 20 + (Math.random() - 0.5) * 30, dir < 0, 16)
|
|
sfxBoing()
|
|
const targetY = loser.pos.y - 20 + (Math.random() - 0.5) * 30
|
|
for (const p of duck) {
|
|
k.tween(p.pos.x, loser.pos.x + (Math.random() - 0.5) * 20, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad)
|
|
k.tween(p.pos.y, targetY, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(duck))
|
|
}
|
|
if (i % 3 === 0) { loser.play(i % 2 === 0 ? 'hit' : 'knockback'); k.shake(4 + i); sfxBonk() }
|
|
await k.wait(0.05)
|
|
}
|
|
sfxExplosion(); k.shake(20); screenFlash('#ffdd00', 0.2)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffdd00')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'QUACK!', '#ffdd00')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 12) {
|
|
// BAGUETTE BEATDOWN
|
|
fatalityTagline = 'BON APPETIT!'
|
|
announceFast('Is that a day-old baguette!?')
|
|
const bread = spawnWeaponProp('baguette', winner.pos.x + dir * 15, winner.pos.y - 25, dir < 0, 18)
|
|
sfxBoing()
|
|
await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
for (let i = 0; i < 6; i++) {
|
|
winner.play(i % 2 === 0 ? 'attack' : 'kick')
|
|
sfxBonk(); loser.play('hit'); k.shake(5 + i * 2)
|
|
for (let c = 0; c < 3; c++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 50, loserOrigY - 30 - Math.random() * 40, 3, 3, '#cc9944', 250, true)
|
|
}
|
|
for (const p of bread) { p.pos.x = loser.pos.x + dir * -10; p.pos.y = loser.pos.y - 25 }
|
|
spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 40 - Math.random() * 15, ['BONK!', 'CRUST!', 'OUI!', 'PAIN!', 'CRUNCH!', 'HON HON!'][i], '#cc9944')
|
|
await k.wait(0.08)
|
|
}
|
|
sfxCritical(); k.shake(18); screenFlash('#cc9944', 0.15)
|
|
loser.play('knockback')
|
|
destroyProp(bread)
|
|
await k.wait(0.3)
|
|
} else if (finishStyle === 13) {
|
|
// GIANT BOOT — Monty Python style
|
|
fatalityTagline = 'BOOTED!'
|
|
announceDramatic('AND NOW FOR SOMETHING COMPLETELY DIFFERENT!')
|
|
sfxDrumRoll()
|
|
const boot = spawnWeaponProp('boot', loser.pos.x, -60, false, 22)
|
|
for (const p of boot) { p.scale = k.vec2(3, 3) }
|
|
await k.wait(0.3)
|
|
sfxZoomWhoosh()
|
|
for (const p of boot) {
|
|
k.tween(p.pos.y, p.pos.y + GROUND_Y + 60, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad)
|
|
}
|
|
await k.wait(0.15)
|
|
sfxExplosion(); sfxSplat(); k.shake(30); screenFlash('#553322', 0.2)
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#553322')
|
|
spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#442211')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'STOMP!', '#553322')
|
|
await k.wait(0.5)
|
|
destroyProp(boot)
|
|
winner.play('win')
|
|
} else if (finishStyle === 14) {
|
|
// CANNON LAUNCH — fires loser out of a cannon
|
|
fatalityTagline = 'FIRED!'
|
|
announceDramatic('LOAD THE CANNON!')
|
|
const cannonProp = spawnWeaponProp('cannon', winner.pos.x + dir * 30, GROUND_Y - 15, dir < 0, 14)
|
|
sfxPowerUp(); await k.wait(0.3)
|
|
sfxZoomWhoosh()
|
|
await k.tween(loser.pos.x, winner.pos.x + dir * 30, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad)
|
|
loser.play('knockback'); loser.opacity = 0.3
|
|
sfxBoing(); await k.wait(0.2)
|
|
sfxGunshot(); sfxExplosion(); k.shake(25)
|
|
loser.opacity = 1; screenFlash('#ff6600', 0.2)
|
|
spawnSparks(winner.pos.x + dir * 50, GROUND_Y - 20, 20, '#ff6600')
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(loser.pos.y, loserOrigY - 150, 0.15, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
sfxBonk(); k.shake(15)
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff4400')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOM!', '#ff6600')
|
|
destroyProp(cannonProp)
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 15) {
|
|
// GIANT STAMP — REJECTED
|
|
fatalityTagline = 'REJECTED!'
|
|
announceRobot('Processing termination request.')
|
|
sfxDrumRoll()
|
|
const stamp = spawnWeaponProp('stamp', loser.pos.x, loser.pos.y - 80, false, 22)
|
|
await k.wait(0.3)
|
|
sfxZoomWhoosh()
|
|
for (const p of stamp) {
|
|
k.tween(p.pos.y, p.pos.y + 60, 0.1, (v) => { p.pos.y = v }, k.easings.easeInQuad)
|
|
}
|
|
await k.wait(0.1)
|
|
sfxBonk(); sfxSplat(); k.shake(20); screenFlash('#cc4444', 0.2)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'REJECTED', '#cc4444')
|
|
for (let i = 0; i < 8; i++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 20 - Math.random() * 40, 4, 4, '#cc4444', 200, true)
|
|
}
|
|
await k.wait(0.4)
|
|
destroyProp(stamp)
|
|
winner.play('win')
|
|
} else if (finishStyle === 16) {
|
|
// VENDING MACHINE TIP
|
|
fatalityTagline = 'OUT OF ORDER!'
|
|
announceSilly('Should have read the warning label!')
|
|
const vm = spawnWeaponProp('vending_machine', winner.pos.x + dir * 40, GROUND_Y - 18, false, 14)
|
|
sfxPowerUp(); await k.wait(0.2)
|
|
winner.play('attack'); sfxPunch()
|
|
sfxZoomWhoosh()
|
|
for (const p of vm) {
|
|
k.tween(p.pos.x, loser.pos.x, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad)
|
|
}
|
|
await k.wait(0.2)
|
|
sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#cc3333', 0.2)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#cc3333')
|
|
const snackColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff8844']
|
|
for (let i = 0; i < 8; i++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 100, GROUND_Y - 40 - Math.random() * 60, 5, 8, snackColors[i % 5], 250)
|
|
}
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CRUSHED!', '#cc3333')
|
|
destroyProp(vm)
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 17) {
|
|
// LAWN MOWER — mowed down
|
|
fatalityTagline = 'MOWED DOWN!'
|
|
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++) { 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)
|
|
}
|
|
await k.tween(winner.pos.x, loser.pos.x - dir * 20, 0.2, (v) => { winner.pos.x = v }, k.easings.easeInQuad)
|
|
sfxExplosion(); k.shake(20); screenFlash('#44aa44', 0.2)
|
|
loser.play('knockback')
|
|
for (let i = 0; i < 15; i++) {
|
|
spawnProp(loser.pos.x, GROUND_Y - 5, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 30 - Math.random() * 50, 3, 3, '#44aa44', 300, false)
|
|
}
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BRRRRR!', '#44aa44')
|
|
destroyProp(mower)
|
|
await k.wait(0.3)
|
|
} else if (finishStyle === 18) {
|
|
// DUBSTEP DROP — bass drops, floor breaks
|
|
fatalityTagline = 'DROP THE BASS!'
|
|
announceScream('WUB WUB WUB!')
|
|
sfxPowerUp()
|
|
const wubColors = ['#ff00ff', '#00ffff', '#ff8800', '#39ff14']
|
|
for (let i = 0; i < 4; i++) {
|
|
screenFlash(wubColors[i], 0.05); k.shake(3 + i * 2); await k.wait(0.1)
|
|
}
|
|
sfxExplosion(); sfxExplosion(); k.shake(35); screenFlash('#ffffff', 0.15)
|
|
spawnShockwave(W / 2, GROUND_Y, '#ff00ff')
|
|
await k.wait(0.05)
|
|
spawnShockwave(W / 2, GROUND_Y, '#00ffff')
|
|
await k.wait(0.05)
|
|
spawnShockwave(W / 2, GROUND_Y, '#39ff14')
|
|
loser.play('knockback')
|
|
for (let i = 0; i < 5; i++) {
|
|
await k.tween(loser.pos.y, loserOrigY - 40, 0.04, (v) => { loser.pos.y = v }, k.easings.easeOutQuad)
|
|
sfxBonk(); k.shake(8)
|
|
await k.tween(loser.pos.y, loserOrigY, 0.04, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
screenFlash(wubColors[i % 4], 0.03)
|
|
}
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff00ff')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'WUBWUB!', '#ff00ff')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 19) {
|
|
// 404 NOT FOUND — loser glitches out
|
|
fatalityTagline = 'ERROR 404!'
|
|
announceRobot('Error. Opponent not found.')
|
|
sfxZap()
|
|
for (let i = 0; i < 10; i++) {
|
|
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); sfxRandomFail()
|
|
await k.wait(0.06)
|
|
}
|
|
screenFlash('#0000ff', 0.3); sfxFail(); k.shake(20)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, '404', '#00ff00')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'NOT FOUND', '#00ff00')
|
|
loser.opacity = 0; await k.wait(0.3)
|
|
loser.opacity = 1; sfxWomp()
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#00ff00')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 20) {
|
|
// PIE STORM — pies from everywhere
|
|
fatalityTagline = 'CREAM ATED!'
|
|
announceSilly('PIE PIE PIE!')
|
|
winner.play('special')
|
|
const pieAngles = [0, 45, 90, 135, 180, 225, 270, 315]
|
|
for (let i = 0; i < 8; i++) {
|
|
const rad = pieAngles[i] * Math.PI / 180
|
|
const fromX = loser.pos.x + Math.cos(rad) * 120
|
|
const fromY = loser.pos.y - 20 + Math.sin(rad) * 80
|
|
const pie = spawnWeaponProp('pie', fromX, fromY, false, 16)
|
|
for (const p of pie) {
|
|
k.tween(p.pos.x, loser.pos.x, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad)
|
|
k.tween(p.pos.y, loser.pos.y - 20, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(pie))
|
|
}
|
|
sfxBoing(); await k.wait(0.06)
|
|
if (i % 2 === 0) { loser.play(i % 4 === 0 ? 'hit' : 'knockback'); sfxSplat(); k.shake(5 + i) }
|
|
}
|
|
sfxExplosion(); k.shake(22); screenFlash('#f5deb3', 0.2)
|
|
for (let i = 0; i < 15; i++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 100, loserOrigY - 40 - Math.random() * 50, 5, 5, '#ffffff', 250, true)
|
|
}
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SPLAT!', '#ffffff')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 21) {
|
|
// T-SHIRT CANNON — rapid fire merch
|
|
fatalityTagline = "MERCH'D!"
|
|
announceFast('FREE SHIRTS! FREE SHIRTS!')
|
|
winner.play('special')
|
|
const tCannon = k.add([k.rect(30, 15), k.pos(winner.pos.x + dir * 15, winner.pos.y - 25), k.color(safeColor(k, '#ff4488')), k.z(16)])
|
|
sfxPowerUp(); await k.wait(0.15)
|
|
const shirtColors = ['#ff4444', '#4444ff', '#44ff44', '#ffff44', '#ff44ff', '#44ffff', '#ff8844', '#8844ff']
|
|
for (let i = 0; i < 8; i++) {
|
|
sfxGunshot()
|
|
spawnProp(tCannon.pos.x + dir * 30, tCannon.pos.y, loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20 + (Math.random() - 0.5) * 20, 10, 8, shirtColors[i], 600)
|
|
if (i % 2 === 0) { loser.play('hit'); sfxBonk(); k.shake(5 + i) }
|
|
await k.wait(0.06)
|
|
}
|
|
sfxCritical(); k.shake(18); screenFlash('#ff4488', 0.2)
|
|
loser.play('knockback')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'XL!', '#ff4488')
|
|
tCannon.destroy()
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 22) {
|
|
// CONFETTI CANNON — party's over
|
|
fatalityTagline = "PARTY'S OVER!"
|
|
announceSilly('SURPRISE!')
|
|
winner.play('special'); sfxPowerUp(); await k.wait(0.2)
|
|
sfxExplosion(); k.shake(20); screenFlash('#ff88cc', 0.15)
|
|
const confettiColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff00', '#ff00ff', '#00ffff', '#ff8800', '#88ff00']
|
|
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(); 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)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'YAY?', '#ff88cc')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 23) {
|
|
// BALLOON POP — inflate then pop
|
|
fatalityTagline = 'POPPED!'
|
|
announceSilly('Inflate! INFLATE!')
|
|
winner.play('special'); sfxPowerUp()
|
|
await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
const origSX = loser.scale.x; const origSY = loser.scale.y
|
|
for (let i = 0; i < 8; i++) {
|
|
const grow = 1 + (i + 1) * 0.15
|
|
await k.tween(loser.scale.y, origSY * grow, 0.08, (v) => { loser.scale.x = origSX * grow; loser.scale.y = v }, k.easings.easeOutQuad)
|
|
sfxBoing(); loser.play(i % 2 === 0 ? 'hit' : 'idle')
|
|
loser.pos.y = loserOrigY - (grow - 1) * 15
|
|
await k.wait(0.04)
|
|
}
|
|
sfxExplosion(); k.shake(25); screenFlash('#ff4488', 0.2)
|
|
loser.scale.x = origSX; loser.scale.y = origSY; loser.pos.y = loserOrigY
|
|
const balloonColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff']
|
|
for (let i = 0; i < 15; i++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 60 - Math.random() * 80, 6, 4, balloonColors[i % 5], 250, true)
|
|
}
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'POP!', '#ff4488')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 24) {
|
|
// GIANT MAGNET — yanks loser back and forth
|
|
fatalityTagline = 'ATTRACTIVE FINISH!'
|
|
announceRobot('Magnetic field activated.')
|
|
winner.play('special'); sfxPowerUp()
|
|
const magnet = k.add([k.rect(20, 25), k.pos(winner.pos.x + dir * 20, winner.pos.y - 30), k.color(safeColor(k, '#cc0000')), k.z(18)])
|
|
const magnetTop = k.add([k.rect(20, 8), k.pos(winner.pos.x + dir * 20, winner.pos.y - 50), k.color(safeColor(k, '#0000cc')), k.z(18)])
|
|
sfxZap()
|
|
for (let i = 0; i < 6; i++) {
|
|
const pullTarget = i % 2 === 0 ? winner.pos.x + dir * 40 : (winningSide === 'a' ? HOME_B + 30 : HOME_A - 30)
|
|
sfxZoomWhoosh()
|
|
await k.tween(loser.pos.x, pullTarget, 0.1, (v) => { loser.pos.x = v }, k.easings.easeInOutQuad)
|
|
sfxBonk(); k.shake(6 + i * 2)
|
|
loser.play(i % 2 === 0 ? 'hit' : 'knockback')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 5, '#8888ff')
|
|
await k.wait(0.05)
|
|
}
|
|
sfxExplosion(); k.shake(22); screenFlash('#8888ff', 0.2)
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#8888ff')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BZZT!', '#8888ff')
|
|
magnet.destroy(); magnetTop.destroy()
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 25) {
|
|
// SLIDE WHISTLE — loser slides off screen
|
|
fatalityTagline = 'WHEEEE!'
|
|
announceSilly('Bye bye!')
|
|
sfxSlideUp(); winner.play('attack')
|
|
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
sfxPunch(); sfxBoing(); k.shake(10)
|
|
loser.play('knockback')
|
|
sfxSlideUp()
|
|
await Promise.all([
|
|
k.tween(loser.pos.y, -50, 0.5, (v) => { loser.pos.y = v }, k.easings.easeInQuad),
|
|
k.tween(loser.pos.x, loser.pos.x + dir * 30, 0.5, (v) => { loser.pos.x = v }, k.easings.easeInQuad),
|
|
])
|
|
spawnEmoteText(loser.pos.x, 20, '*', '#ffee00')
|
|
await k.wait(0.4)
|
|
sfxSlideDown(); sfxZoomWhoosh()
|
|
await k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
sfxSplat(); sfxExplosion(); k.shake(20); screenFlash('#ffcc00', 0.15)
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ffcc00')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 26) {
|
|
// SLAPSTICK LADDER — keeps bonking loser
|
|
fatalityTagline = 'SLAPSTICKED!'
|
|
announceSilly('Watch where you swing that thing!')
|
|
const ladder = k.add([k.rect(6, 50), k.pos(winner.pos.x + dir * 5, winner.pos.y - 40), k.color(safeColor(k, '#cc8833')), k.z(18), k.anchor('center')])
|
|
sfxBoing()
|
|
for (let i = 0; i < 5; i++) {
|
|
winner.scale.x = -winner.scale.x
|
|
ladder.pos.x = winner.pos.x + (winner.scale.x > 0 ? 1 : -1) * 25
|
|
sfxZoomWhoosh(); await k.wait(0.06)
|
|
sfxBonk(); k.shake(6 + i * 2)
|
|
loser.play(i % 2 === 0 ? 'hit' : 'knockback')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 40 - Math.random() * 20, ['BONK!', 'CLONK!', 'WHACK!', 'THUD!', 'OOF!'][i], '#cc8833')
|
|
await k.wait(0.08)
|
|
}
|
|
sfxCritical(); k.shake(18); screenFlash('#cc8833', 0.15)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#cc8833')
|
|
ladder.destroy()
|
|
winner.scale.x = dir > 0 ? Math.abs(winner.scale.x) : -Math.abs(winner.scale.x)
|
|
spawnEmoteText(winner.pos.x, winner.pos.y - 50, '?', '#ffffff')
|
|
await k.wait(0.3)
|
|
} else if (finishStyle === 27) {
|
|
// STACK OVERFLOW — buried in code blocks
|
|
fatalityTagline = 'STACK OVERFLOW!'
|
|
announceRobot('Exception in thread main.')
|
|
sfxFail()
|
|
const blockColors = ['#282c34', '#1e1e1e', '#2d2d2d', '#1a1a2e', '#0d1117']
|
|
for (let i = 0; i < 10; i++) {
|
|
const block = k.add([
|
|
k.rect(18 + Math.random() * 12, 8 + Math.random() * 6),
|
|
k.pos(loser.pos.x + (Math.random() - 0.5) * 40, -20 - i * 15),
|
|
k.color(safeColor(k, blockColors[i % 5])), k.z(16 + i), k.opacity(0.9),
|
|
])
|
|
k.tween(block.pos.y, GROUND_Y - 5 - i * 6, 0.15 + i * 0.02, (v) => { block.pos.y = v }, k.easings.easeInQuad).then(() => {
|
|
sfxBonk(); k.shake(3)
|
|
setTimeout(() => { if (block.exists()) k.tween(1, 0, 0.8, (v) => { block.opacity = v }).then(() => { if (block.exists()) block.destroy() }) }, 800)
|
|
})
|
|
spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 30 - Math.random() * 30, ['{', '}', '()', '=>', '[]', ';;', 'nil', 'NaN', '???', '//'][i], '#00ff00')
|
|
loser.play(i % 3 === 0 ? 'hit' : 'idle')
|
|
await k.wait(0.06)
|
|
}
|
|
sfxExplosion(); k.shake(20); screenFlash('#00ff00', 0.2)
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SEGFAULT', '#ff0000')
|
|
await k.wait(0.4)
|
|
winner.play('win')
|
|
} else if (finishStyle === 28) {
|
|
// GLITTER BOMB — fabulous destruction
|
|
fatalityTagline = 'FABULOUS!'
|
|
announceSilly('You will NEVER get this out of your hair!')
|
|
winner.play('special'); sfxPowerUp()
|
|
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
sfxPunch(); k.shake(5)
|
|
sfxExplosion(); k.shake(25)
|
|
const glitterColors = ['#ff88cc', '#ffcc00', '#88ffcc', '#cc88ff', '#ff8888', '#88ccff', '#ffff88', '#ff88ff']
|
|
for (let wave = 0; wave < 3; wave++) {
|
|
screenFlash(glitterColors[wave * 2], 0.04)
|
|
for (let i = 0; i < 15; i++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 140, loserOrigY - 80 - Math.random() * 80, 3, 3, glitterColors[Math.floor(Math.random() * 8)], 200 + Math.random() * 200, Math.random() > 0.5)
|
|
}
|
|
sfxBoing(); loser.play(wave === 2 ? 'knockback' : 'hit')
|
|
await k.wait(0.1)
|
|
}
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff88cc')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SPARKLE!', '#ff88cc')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 29) {
|
|
// SELFIE STICK — beats then takes selfie
|
|
fatalityTagline = 'SAY CHEESE!'
|
|
announceFast('Content creation time!')
|
|
const stick = k.add([k.rect(4, 40), k.pos(winner.pos.x + dir * 10, winner.pos.y - 50), k.color(safeColor(k, '#888888')), k.z(18)])
|
|
const phone = k.add([k.rect(8, 12), k.pos(winner.pos.x + dir * 10, winner.pos.y - 70), k.color(safeColor(k, '#222222')), k.z(19)])
|
|
sfxBoing()
|
|
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
for (let i = 0; i < 4; i++) {
|
|
winner.play('attack'); sfxBonk()
|
|
stick.pos.x = loser.pos.x; phone.pos.x = loser.pos.x
|
|
loser.play('hit'); k.shake(5 + i * 2)
|
|
await k.wait(0.08)
|
|
}
|
|
sfxCritical(); k.shake(15); loser.play('knockback')
|
|
await k.wait(0.15)
|
|
winner.play('win'); sfxCoin(); screenFlash('#ffffff', 0.3)
|
|
spawnEmoteText(W / 2, GROUND_Y - 80, '#selfie', '#ff4488')
|
|
spawnSparks(phone.pos.x, phone.pos.y, 10, '#ffffff')
|
|
stick.destroy(); phone.destroy()
|
|
await k.wait(0.3)
|
|
} else if (finishStyle === 30) {
|
|
// DANCE OFF — served
|
|
fatalityTagline = 'SERVED!'
|
|
announceHype('DANCE BATTLE!')
|
|
sfxPowerUp()
|
|
const moves = ['attack', 'kick', 'special', 'kick', 'attack', 'special'] as const
|
|
for (let i = 0; i < moves.length; i++) {
|
|
winner.play(moves[i]); sfxRandomComedy()
|
|
spawnSparks(winner.pos.x, winner.pos.y - 30, 3, ['#ff00ff', '#00ffff', '#ffff00'][i % 3])
|
|
await k.wait(0.08)
|
|
}
|
|
for (let i = 0; i < 4; i++) {
|
|
loser.play(moves[i]); sfxWomp()
|
|
loser.pos.x += (Math.random() - 0.5) * 10
|
|
await k.wait(0.1)
|
|
}
|
|
sfxFail()
|
|
winner.play('special'); sfxCritical(); sfxExplosion()
|
|
k.shake(25); screenFlash('#ff00ff', 0.2)
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff00ff')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SERVED!', '#ff00ff')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 31) {
|
|
// WHOOPEE CUSHION — total gas
|
|
fatalityTagline = 'TOTAL GAS!'
|
|
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(); 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++) {
|
|
const gas = k.add([
|
|
k.circle(8 + Math.random() * 10),
|
|
k.pos(loser.pos.x + (Math.random() - 0.5) * 40, GROUND_Y - 10 - Math.random() * 30),
|
|
k.color(safeColor(k, '#88ff44')), k.opacity(0.5), k.z(15),
|
|
])
|
|
k.tween(gas.pos.y, gas.pos.y - 40 - Math.random() * 30, 0.6, (v) => { gas.pos.y = v }, k.easings.easeOutQuad)
|
|
k.tween(0.5, 0, 0.6, (v) => { gas.opacity = v }).then(() => { if (gas.exists()) gas.destroy() })
|
|
}
|
|
k.shake(20); screenFlash('#88ff44', 0.15)
|
|
loser.play('knockback')
|
|
await k.tween(loser.pos.y, loserOrigY - 120, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
sfxSplat(); k.shake(12)
|
|
cushion.destroy()
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'P U!', '#88ff44')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else if (finishStyle === 32) {
|
|
// RICK ROLL — never gonna give you up
|
|
fatalityTagline = 'NEVER GONNA GIVE YOU UP!'
|
|
announceHype('You know the rules, and so do I!')
|
|
sfxPowerUp()
|
|
const rickMoves = ['attack', 'kick', 'special', 'idle', 'attack', 'kick'] as const
|
|
for (let i = 0; i < 6; i++) {
|
|
winner.play(rickMoves[i])
|
|
const hop = winner.pos.y
|
|
await k.tween(winner.pos.y, hop - 15, 0.05, (v) => { winner.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(winner.pos.y, hop, 0.05, (v) => { winner.pos.y = v }, k.easings.easeInQuad)
|
|
sfxBoing()
|
|
screenFlash(['#ff4444', '#ff8800', '#ffff00', '#44ff44', '#4444ff', '#8844ff'][i], 0.03)
|
|
await k.wait(0.04)
|
|
}
|
|
await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad)
|
|
winner.play('special'); sfxCritical(); sfxExplosion()
|
|
k.shake(25); screenFlash('#ff8800', 0.2)
|
|
loser.play('knockback')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff8800')
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff8800')
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, "RICK'D!", '#ff8800')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
} else {
|
|
// PIZZA CUTTER — the big wheel
|
|
fatalityTagline = 'SLICED!'
|
|
announceFast('Extra large, extra lethal!')
|
|
const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor(k, '#cccccc')), k.z(18), k.rotate(0)])
|
|
const handle = k.add([k.rect(6, 20), k.pos(winner.pos.x + dir * 30, GROUND_Y - 36), k.color(safeColor(k, '#553322')), k.z(17)])
|
|
sfxPowerUp(); winner.play('special')
|
|
sfxZoomWhoosh()
|
|
await Promise.all([
|
|
k.tween(wheel.pos.x, loser.pos.x, 0.2, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(0, 720, 0.2, (v) => { wheel.angle = v }, k.easings.linear),
|
|
])
|
|
sfxCritical(); sfxSplat(); k.shake(20); screenFlash('#ffcc00', 0.15)
|
|
loser.play('knockback')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ff6600')
|
|
await k.tween(wheel.pos.x, loser.pos.x + dir * 60, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeOutQuad)
|
|
sfxZoomWhoosh()
|
|
await k.tween(wheel.pos.x, loser.pos.x, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad)
|
|
sfxBonk(); k.shake(15)
|
|
for (let i = 0; i < 8; i++) {
|
|
spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 30 - Math.random() * 40, 4, 4, i % 2 === 0 ? '#ffcc00' : '#cc4400', 250, true)
|
|
}
|
|
wheel.destroy(); handle.destroy()
|
|
spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLICED!', '#ff6600')
|
|
await k.wait(0.3)
|
|
winner.play('win')
|
|
}
|
|
|
|
// Judge stands up for KO call
|
|
const judgeKO = k.get('judge')[0]
|
|
if (judgeKO) {
|
|
judgeKO.play('shocked')
|
|
const judgeOrigY = judgeKO.pos.y
|
|
k.tween(judgeKO.pos.y, judgeOrigY - 25, 0.2, (v) => { judgeKO.pos.y = v }, k.easings.easeOutQuad)
|
|
k.wait(1.5).then(() => {
|
|
if (judgeKO.exists()) {
|
|
judgeKO.play(winningSide === 'a' ? 'call_left' : 'call_right')
|
|
k.tween(judgeKO.pos.y, judgeOrigY, 0.3, (v) => { judgeKO.pos.y = v }, k.easings.easeInOutQuad)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Winner's human cheers, loser's human panics (30% chance on KO)
|
|
if (Math.random() < 0.3) {
|
|
spawnHumanCoach(winningSide, 'cheer')
|
|
spawnHumanCoach(winningSide === 'a' ? 'b' : 'a', 'panic')
|
|
}
|
|
|
|
// === Common ending: KO + celebration ===
|
|
sfxKO()
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
|
|
spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff2d2d')
|
|
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
|
|
if (winnerArch === 'the_creator') {
|
|
announceCreatorKO()
|
|
} else {
|
|
announceFatality(fatalityTagline)
|
|
}
|
|
announceCrowdReaction('cheer')
|
|
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.25, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
|
await k.wait(0.2)
|
|
winner.play('win')
|
|
sfxWin()
|
|
// Creator win/lose get special announcements
|
|
if (winnerArch === 'the_creator') {
|
|
announceCreatorWin()
|
|
} else if (loserArch === 'the_creator') {
|
|
announceCreatorLose()
|
|
} else {
|
|
sfxWinAnnounce(winnerName)
|
|
}
|
|
spawnSparks(winner.pos.x, winner.pos.y - 50, 15, '#ffe14d')
|
|
for (let i = 0; i < 4; i++) {
|
|
setTimeout(() => {
|
|
spawnSparks(winner.pos.x + (Math.random() - 0.5) * 60, winner.pos.y - 40 - Math.random() * 30, 8, '#ffe14d')
|
|
}, i * 200)
|
|
}
|
|
await k.wait(0.5)
|
|
|
|
// Post-KO sportsmanship (30% chance): winner helps loser up or they fist bump
|
|
if (Math.random() < 0.3) {
|
|
const sportsType = Math.random()
|
|
if (sportsType < 0.35) {
|
|
// Winner helps loser back up
|
|
await playHelpUp(winner, loser, winningSide)
|
|
} else if (sportsType < 0.65) {
|
|
// Fist bump
|
|
loser.play('idle')
|
|
await k.wait(0.2)
|
|
await playFistBump(winner, loser)
|
|
} else if (sportsType < 0.85) {
|
|
// Both bow
|
|
loser.play('idle')
|
|
await k.wait(0.2)
|
|
await Promise.all([playBow(winner), playBow(loser)])
|
|
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
|
for (let i = 0; i < 5; i++) spawnHeart(W / 2 + (Math.random() - 0.5) * 100, GROUND_Y - 60)
|
|
} else {
|
|
// Crowd shows love — signs pop up
|
|
spawnCrowdSigns(5, '#ff4466', '\u2665')
|
|
announceDramatic(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
|
for (let i = 0; i < 8; i++) spawnHeart(Math.random() * W, GROUND_Y - 40 - Math.random() * 60)
|
|
await k.wait(0.8)
|
|
}
|
|
}
|
|
},
|
|
|
|
async playPerfect(winningSide: 'a' | 'b', winnerName: string) {
|
|
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
|
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter
|
|
if (!loser || !winner) return
|
|
|
|
// Judge goes wild for a perfect
|
|
const judgePerfect = k.get('judge')[0]
|
|
if (judgePerfect) {
|
|
judgePerfect.play('shocked')
|
|
const jOrigY = judgePerfect.pos.y
|
|
k.tween(judgePerfect.pos.y, jOrigY - 35, 0.15, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad)
|
|
k.wait(0.5).then(async () => {
|
|
if (!judgePerfect.exists()) return
|
|
for (let b = 0; b < 4; b++) {
|
|
await k.tween(judgePerfect.pos.y, jOrigY - 45, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad)
|
|
await k.tween(judgePerfect.pos.y, jOrigY - 35, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeInQuad)
|
|
}
|
|
judgePerfect.play(winningSide === 'a' ? 'call_left' : 'call_right')
|
|
k.tween(judgePerfect.pos.y, jOrigY, 0.3, (v) => { judgePerfect.pos.y = v }, k.easings.easeInOutQuad)
|
|
})
|
|
}
|
|
|
|
sfxPerfect()
|
|
const dir = winningSide === 'a' ? 1 : -1
|
|
const loserOrigY = loser.pos.y
|
|
const contactX = loser.pos.x - dir * 40
|
|
|
|
// === PHASE 1: Dramatic zoom rush into the loser ===
|
|
const origScaleX = winner.scale.x
|
|
const origScaleY = winner.scale.y
|
|
sfxZoomWhoosh()
|
|
// Winner zooms at camera
|
|
await Promise.all([
|
|
k.tween(Math.abs(origScaleX), 5, 0.2, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad),
|
|
k.tween(winner.pos.x, W / 2, 0.2, (v) => { winner.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(winner.pos.y, H * 0.6, 0.2, (v) => { winner.pos.y = v }, k.easings.easeOutQuad),
|
|
])
|
|
screenFlash('#000000', 0.1)
|
|
spawnGrotesqueDetails(winner, 2.5)
|
|
await k.wait(0.1)
|
|
destroyGrotesqueDetails()
|
|
// Zoom back and SLAM into loser
|
|
sfxZoomWhoosh()
|
|
await Promise.all([
|
|
k.tween(winner.scale.y, Math.abs(origScaleY), 0.12, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad),
|
|
k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeInQuad),
|
|
k.tween(winner.pos.y, loserOrigY, 0.12, (v) => { winner.pos.y = v }, k.easings.easeInQuad),
|
|
])
|
|
|
|
// === PHASE 2: Devastating rapid combo at contact ===
|
|
const colors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#ff6600', '#b83dff', '#39ff14', '#ffffff', '#ff2d2d']
|
|
const hitCount = 10
|
|
for (let i = 0; i < hitCount; i++) {
|
|
const anim = ['attack', 'kick', 'special', 'attack'][i % 4]
|
|
winner.play(anim)
|
|
sfxRapidPunch()
|
|
await k.wait(0.04)
|
|
loser.play(i < hitCount - 1 ? 'hit' : 'knockback')
|
|
k.shake(3 + i)
|
|
spawnSparks(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 10 - Math.random() * 50, 5, colors[i % colors.length])
|
|
loser.pos.x += dir * 4
|
|
loser.pos.y += (i % 2 === 0 ? -3 : 3)
|
|
await k.wait(0.04)
|
|
}
|
|
loser.pos.y = loserOrigY
|
|
|
|
// === PHASE 3: Final massive hit — screen goes white ===
|
|
winner.play('special')
|
|
sfxCritical()
|
|
sfxExplosion()
|
|
await k.wait(0.06)
|
|
loser.play('knockback')
|
|
k.shake(35)
|
|
screenFlash('#ffe14d', 0.4)
|
|
spawnSparks(loser.pos.x, loser.pos.y - 30, 30, '#ffe14d')
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d')
|
|
|
|
// Launch loser way off with a spin
|
|
await Promise.all([
|
|
k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad),
|
|
k.tween(loser.pos.y, loserOrigY - 300, 0.25, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() =>
|
|
k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad)
|
|
),
|
|
])
|
|
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
|
|
spawnSparks(loser.pos.x, GROUND_Y - 10, 30, '#ff2d2d')
|
|
k.shake(20)
|
|
sfxExplosion()
|
|
sfxBoing()
|
|
await k.wait(0.3)
|
|
loser.play('ko')
|
|
|
|
// Winner walks back and celebrates
|
|
winner.scale.x = origScaleX
|
|
winner.scale.y = origScaleY
|
|
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
|
winner.play('win')
|
|
setTimeout(() => {
|
|
sfxWin()
|
|
sfxWinAnnounce(winnerName)
|
|
}, 300)
|
|
// FlawlessVictory voice is called by FightViewer after playPerfect returns
|
|
announceCrowdReaction('cheer')
|
|
// Massive fireworks
|
|
for (let i = 0; i < 8; i++) {
|
|
setTimeout(() => {
|
|
spawnSparks(
|
|
Math.random() * W,
|
|
Math.random() * H * 0.5,
|
|
18,
|
|
['#ff2d7b', '#00f0ff', '#ffe14d', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i]
|
|
)
|
|
if (i % 2 === 0) sfxRandomComedy()
|
|
}, i * 200)
|
|
}
|
|
await k.wait(0.7)
|
|
},
|
|
|
|
startMusic() { startMusic() },
|
|
stopMusic() { stopMusic() },
|
|
|
|
destroy() {
|
|
stopMusic()
|
|
k.quit()
|
|
},
|
|
}
|
|
}
|
|
|
|
export type FightSceneController = Awaited<ReturnType<typeof createFightScene>>
|