2026-03-06 16:27:54 +00:00
|
|
|
import kaplay from 'kaplay'
|
2026-03-06 22:13:19 +00:00
|
|
|
import { generateSpriteSheet, generateJudgeSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './sprites'
|
|
|
|
|
import {
|
|
|
|
|
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
|
|
|
|
|
sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect,
|
|
|
|
|
sfxRoundStart, sfxBlock, sfxDodge, sfxClash, sfxRandomSilly, sfxBoing,
|
|
|
|
|
sfxBonk, sfxZap, sfxSlideDown, sfxZoomWhoosh, sfxRapidPunch,
|
|
|
|
|
fanfareRound, fanfareFight, fanfareDevastating, fanfareCritical, fanfareCombo,
|
|
|
|
|
startMusic, stopMusic, announce,
|
|
|
|
|
} from './sounds'
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
export interface FightSceneConfig {
|
|
|
|
|
canvas: HTMLCanvasElement
|
2026-03-06 22:13:19 +00:00
|
|
|
botA: { name: string; seed: string; tier: number; archetype?: string }
|
|
|
|
|
botB: { name: string; seed: string; tier: number; archetype?: string }
|
2026-03-06 16:27:54 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' },
|
2026-03-06 22:13:19 +00:00
|
|
|
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' },
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// 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'], 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'], 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'], generic: ['dashPunch', 'flyingKick', 'uppercut', 'zoomRush', 'bombThrow', 'minigunSpray', 'pocketCannon'] },
|
|
|
|
|
creative_writing: { themed: ['penStab', 'scrollBlast', 'bookStorm', 'enchantedArrow'], generic: ['jetpackDive', 'aerialSlam', 'projectile', 'rapidFlurry', 'katanaCombo', 'arcaneBarrage', 'memeBeam'] },
|
|
|
|
|
math_blitz: { themed: ['mathAttack', 'dataStream', 'pixelBeam', 'hackerAttack'], 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'], generic: ['laserSword', 'plasmaSword', 'asteroidBelt', 'satelliteLaser', 'plasmaBeam', 'solarFlare'] },
|
|
|
|
|
hack_battle: { themed: ['hackerAttack', 'bugSwarm', 'stackOverflow', 'segfault', 'blueScreen', 'malwareInject'], 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'] },
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
function pickChoreography(challengeType: string, isCritical: boolean, _round: number): string {
|
|
|
|
|
// 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',
|
|
|
|
|
]
|
|
|
|
|
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)]
|
|
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export 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)
|
2026-03-06 22:13:19 +00:00
|
|
|
const sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype)
|
|
|
|
|
const sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype)
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
|
|
|
|
k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// 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 },
|
|
|
|
|
}
|
|
|
|
|
k.loadSprite('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
const W = k.width()
|
|
|
|
|
const H = k.height()
|
|
|
|
|
const GROUND_Y = H * 0.78
|
2026-03-06 22:13:19 +00:00
|
|
|
const HOME_A = W * 0.28
|
|
|
|
|
const HOME_B = W * 0.72
|
|
|
|
|
|
|
|
|
|
// Particle helpers
|
|
|
|
|
function spawnSparks(x: number, y: number, count: number, color: string) {
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
const angle = Math.random() * Math.PI * 2
|
|
|
|
|
const speed = 100 + Math.random() * 300
|
|
|
|
|
const size = 2 + Math.random() * 4
|
|
|
|
|
const spark = k.add([
|
|
|
|
|
k.rect(size, size),
|
|
|
|
|
k.pos(x, y),
|
|
|
|
|
k.color(k.Color.fromHex(color)),
|
|
|
|
|
k.opacity(1),
|
|
|
|
|
k.z(20),
|
|
|
|
|
k.rotate(Math.random() * 360),
|
|
|
|
|
{ vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 100 },
|
|
|
|
|
])
|
|
|
|
|
spark.onUpdate(() => {
|
|
|
|
|
spark.pos.x += (spark as any).vx * k.dt()
|
|
|
|
|
spark.pos.y += (spark as any).vy * k.dt()
|
|
|
|
|
;(spark as any).vy += 400 * k.dt() // gravity
|
|
|
|
|
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(k.Color.fromHex('#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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex('#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(k.Color.fromHex(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(k.Color.fromHex(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: any[] = []
|
|
|
|
|
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(k.Color.fromHex(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: any[] = []
|
|
|
|
|
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(k.Color.fromHex(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(k.Color.fromHex(colors[0])), k.opacity(0.08), k.z(1),
|
|
|
|
|
])
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
idx = (idx + 1) % colors.length
|
|
|
|
|
overlay.color = k.Color.fromHex(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]
|
|
|
|
|
const fB = k.get('fighterB')[0]
|
|
|
|
|
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: any[] = []
|
|
|
|
|
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(k.Color.fromHex('#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(k.Color.fromHex(color)),
|
|
|
|
|
k.opacity(0.4),
|
|
|
|
|
k.z(50),
|
|
|
|
|
])
|
|
|
|
|
k.tween(0.4, 0, duration, (v) => { flash.opacity = v }).then(() => flash.destroy())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Ren & Stimpy style grotesque close-up overlays ===
|
|
|
|
|
// When bots scale up big, we overlay gross details: bulging eyes, veins, teeth, sweat
|
|
|
|
|
let grotesqueObjects: any[] = []
|
|
|
|
|
|
|
|
|
|
function spawnGrotesqueDetails(fighter: any, scaleFactor: number) {
|
|
|
|
|
const cx = fighter.pos.x
|
|
|
|
|
const cy = fighter.pos.y
|
|
|
|
|
const dir = fighter.scale.x > 0 ? 1 : -1
|
|
|
|
|
const sz = scaleFactor // bigger = more detail
|
|
|
|
|
|
|
|
|
|
// Bulging bloodshot eye (the iconic Ren & Stimpy look)
|
|
|
|
|
const eyeX = cx + dir * 8 * sz
|
|
|
|
|
const eyeY = cy - 35 * sz
|
|
|
|
|
const eyeWhite = k.add([
|
|
|
|
|
k.circle(7 * sz), k.pos(eyeX, eyeY),
|
|
|
|
|
k.color(k.Color.fromHex('#ffffdd')), k.opacity(0.85), k.z(32),
|
|
|
|
|
])
|
|
|
|
|
grotesqueObjects.push(eyeWhite)
|
|
|
|
|
|
|
|
|
|
// Bloodshot veins on eye
|
|
|
|
|
for (let v = 0; v < 4; v++) {
|
|
|
|
|
const angle = (v / 4) * Math.PI * 2 + Math.random() * 0.5
|
|
|
|
|
const vLen = 4 * sz + Math.random() * 3 * sz
|
|
|
|
|
const vein = k.add([
|
|
|
|
|
k.rect(vLen, 0.8 * sz), k.pos(eyeX, eyeY),
|
|
|
|
|
k.color(k.Color.fromHex('#cc2222')), k.opacity(0.7), k.z(33),
|
|
|
|
|
k.rotate(angle * 180 / Math.PI),
|
|
|
|
|
])
|
|
|
|
|
grotesqueObjects.push(vein)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pupil — twitchy
|
|
|
|
|
const pupil = k.add([
|
|
|
|
|
k.circle(3 * sz), k.pos(eyeX + dir * 2 * sz, eyeY),
|
|
|
|
|
k.color(k.Color.fromHex('#111111')), k.opacity(0.9), k.z(34),
|
|
|
|
|
])
|
|
|
|
|
pupil.onUpdate(() => {
|
|
|
|
|
pupil.pos.x = eyeX + dir * 2 * sz + Math.sin(k.time() * 12) * sz
|
|
|
|
|
pupil.pos.y = eyeY + Math.cos(k.time() * 9) * 0.8 * sz
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(pupil)
|
|
|
|
|
|
|
|
|
|
// Second eye (smaller, off-axis for grotesque asymmetry)
|
|
|
|
|
const eye2X = cx - dir * 4 * sz
|
|
|
|
|
const eye2Y = cy - 33 * sz
|
|
|
|
|
const eye2 = k.add([
|
|
|
|
|
k.circle(5 * sz), k.pos(eye2X, eye2Y),
|
|
|
|
|
k.color(k.Color.fromHex('#ffffcc')), k.opacity(0.8), k.z(32),
|
|
|
|
|
])
|
|
|
|
|
const pupil2 = k.add([
|
|
|
|
|
k.circle(2.5 * sz), k.pos(eye2X - dir * sz, eye2Y + sz),
|
|
|
|
|
k.color(k.Color.fromHex('#111111')), k.opacity(0.85), k.z(34),
|
|
|
|
|
])
|
|
|
|
|
pupil2.onUpdate(() => {
|
|
|
|
|
pupil2.pos.x = eye2X - dir * sz + Math.sin(k.time() * 15 + 1) * 0.8 * sz
|
|
|
|
|
pupil2.pos.y = eye2Y + sz + Math.cos(k.time() * 11 + 1) * 0.6 * sz
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(eye2, pupil2)
|
|
|
|
|
|
|
|
|
|
// Grimacing teeth / grin
|
|
|
|
|
const teethY = cy - 18 * sz
|
|
|
|
|
for (let t = 0; t < 5; t++) {
|
|
|
|
|
const tx = cx + (t - 2) * 4 * sz * dir
|
|
|
|
|
const tooth = k.add([
|
|
|
|
|
k.rect(3 * sz, 4 * sz + Math.random() * 2 * sz),
|
|
|
|
|
k.pos(tx, teethY),
|
|
|
|
|
k.color(k.Color.fromHex(Math.random() > 0.3 ? '#ffffcc' : '#cccc88')),
|
|
|
|
|
k.opacity(0.8), k.z(33),
|
|
|
|
|
])
|
|
|
|
|
grotesqueObjects.push(tooth)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Forehead veins — pulsing
|
|
|
|
|
for (let v = 0; v < 3; v++) {
|
|
|
|
|
const vx = cx + (Math.random() - 0.5) * 18 * sz
|
|
|
|
|
const vy = cy - 42 * sz - Math.random() * 8 * sz
|
|
|
|
|
const vein = k.add([
|
|
|
|
|
k.rect(8 * sz + Math.random() * 6 * sz, 0.7 * sz),
|
|
|
|
|
k.pos(vx, vy),
|
|
|
|
|
k.color(k.Color.fromHex('#6633aa')),
|
|
|
|
|
k.opacity(0.4), k.z(31),
|
|
|
|
|
k.rotate(-20 + Math.random() * 40),
|
|
|
|
|
])
|
|
|
|
|
vein.onUpdate(() => {
|
|
|
|
|
vein.opacity = 0.3 + Math.sin(k.time() * 6 + v * 2) * 0.15
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(vein)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sweat drops — animated falling
|
|
|
|
|
for (let s = 0; s < 2 + Math.floor(Math.random() * 2); s++) {
|
|
|
|
|
const sx = cx + (Math.random() - 0.5) * 20 * sz
|
|
|
|
|
const sy = cy - 40 * sz - Math.random() * 10 * sz
|
|
|
|
|
const drop = k.add([
|
|
|
|
|
k.circle(1.5 * sz), k.pos(sx, sy),
|
|
|
|
|
k.color(k.Color.fromHex('#88ccff')), k.opacity(0.7), k.z(35),
|
|
|
|
|
])
|
|
|
|
|
drop.onUpdate(() => {
|
|
|
|
|
drop.pos.y += 40 * sz * k.dt()
|
|
|
|
|
drop.opacity -= 0.8 * k.dt()
|
|
|
|
|
if (drop.opacity <= 0 && drop.exists()) drop.destroy()
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(drop)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nostril flare
|
|
|
|
|
const nostrilY = cy - 24 * sz
|
|
|
|
|
for (let n = 0; n < 2; n++) {
|
|
|
|
|
const nx = cx + (n === 0 ? -2 : 2) * sz * dir
|
|
|
|
|
const nostril = k.add([
|
|
|
|
|
k.circle(1.8 * sz), k.pos(nx, nostrilY),
|
|
|
|
|
k.color(k.Color.fromHex('#331111')), k.opacity(0.6), k.z(33),
|
|
|
|
|
k.scale(1),
|
|
|
|
|
])
|
|
|
|
|
nostril.onUpdate(() => {
|
|
|
|
|
const pulse = 1 + Math.sin(k.time() * 8) * 0.3
|
|
|
|
|
nostril.scale.x = pulse; nostril.scale.y = pulse
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(nostril)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === RANDOM EXTRA EXPRESSION (varies each close-up) ===
|
|
|
|
|
const expression = Math.floor(Math.random() * 6)
|
|
|
|
|
|
|
|
|
|
if (expression === 0) {
|
|
|
|
|
// RAGE FACE — eyebrows angled down, mouth wide open, steam from ears
|
|
|
|
|
const browL = k.add([
|
|
|
|
|
k.rect(8 * sz, 1.5 * sz), k.pos(eyeX - 4 * sz, eyeY - 6 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#442200')), k.opacity(0.8), k.z(35),
|
|
|
|
|
k.rotate(dir > 0 ? 25 : -25),
|
|
|
|
|
])
|
|
|
|
|
const browR = k.add([
|
|
|
|
|
k.rect(8 * sz, 1.5 * sz), k.pos(eye2X - 4 * sz, eye2Y - 5 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#442200')), k.opacity(0.8), k.z(35),
|
|
|
|
|
k.rotate(dir > 0 ? -25 : 25),
|
|
|
|
|
])
|
|
|
|
|
grotesqueObjects.push(browL, browR)
|
|
|
|
|
// Steam puffs from ears
|
|
|
|
|
for (let s = 0; s < 3; s++) {
|
|
|
|
|
const earX = cx + dir * 18 * sz
|
|
|
|
|
const puff = k.add([
|
|
|
|
|
k.circle(2 * sz + s * sz), k.pos(earX, cy - 30 * sz - s * 5 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#cccccc')), k.opacity(0.5), k.z(36),
|
|
|
|
|
])
|
|
|
|
|
puff.onUpdate(() => {
|
|
|
|
|
puff.pos.y -= 15 * sz * k.dt()
|
|
|
|
|
puff.opacity -= 0.6 * k.dt()
|
|
|
|
|
if (puff.opacity <= 0 && puff.exists()) puff.destroy()
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(puff)
|
|
|
|
|
}
|
|
|
|
|
} else if (expression === 1) {
|
|
|
|
|
// TONGUE OUT — big pink tongue lolling, drool drops
|
|
|
|
|
const tongueX = cx + dir * 2 * sz
|
|
|
|
|
const tongueY = cy - 14 * sz
|
|
|
|
|
const tongue = k.add([
|
|
|
|
|
k.rect(5 * sz, 10 * sz, { radius: 3 * sz }), k.pos(tongueX, tongueY),
|
|
|
|
|
k.color(k.Color.fromHex('#ff6688')), k.opacity(0.8), k.z(34),
|
|
|
|
|
])
|
|
|
|
|
tongue.onUpdate(() => {
|
|
|
|
|
tongue.pos.x = tongueX + Math.sin(k.time() * 6) * sz
|
|
|
|
|
tongue.pos.y = tongueY + Math.sin(k.time() * 4) * 0.5 * sz
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(tongue)
|
|
|
|
|
// Drool
|
|
|
|
|
const drool = k.add([
|
|
|
|
|
k.circle(1.2 * sz), k.pos(tongueX + 2 * sz, tongueY + 10 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#88ccff')), k.opacity(0.6), k.z(35),
|
|
|
|
|
])
|
|
|
|
|
drool.onUpdate(() => {
|
|
|
|
|
drool.pos.y += 25 * sz * k.dt()
|
|
|
|
|
drool.opacity -= 0.4 * k.dt()
|
|
|
|
|
if (drool.opacity <= 0 && drool.exists()) drool.destroy()
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(drool)
|
|
|
|
|
} else if (expression === 2) {
|
|
|
|
|
// CROSS-EYED — both pupils drift toward center
|
|
|
|
|
pupil.onUpdate(() => {
|
|
|
|
|
pupil.pos.x = eyeX - dir * 3 * sz + Math.sin(k.time() * 3) * 0.5 * sz
|
|
|
|
|
pupil.pos.y = eyeY + 2 * sz
|
|
|
|
|
})
|
|
|
|
|
pupil2.onUpdate(() => {
|
|
|
|
|
pupil2.pos.x = eye2X + dir * 3 * sz + Math.sin(k.time() * 3 + 1) * 0.5 * sz
|
|
|
|
|
pupil2.pos.y = eye2Y + 2 * sz
|
|
|
|
|
})
|
|
|
|
|
} else if (expression === 3) {
|
|
|
|
|
// CRYING — tear streams + quivering lip
|
|
|
|
|
for (let side = 0; side < 2; side++) {
|
|
|
|
|
const tearBaseX = side === 0 ? eyeX : eye2X
|
|
|
|
|
const tearBaseY = (side === 0 ? eyeY : eye2Y) + 5 * sz
|
|
|
|
|
for (let t = 0; t < 3; t++) {
|
|
|
|
|
const tear = k.add([
|
|
|
|
|
k.circle(1.2 * sz), k.pos(tearBaseX, tearBaseY),
|
|
|
|
|
k.color(k.Color.fromHex('#4488ff')), k.opacity(0.7), k.z(36),
|
|
|
|
|
])
|
|
|
|
|
const startDelay = t * 0.3
|
|
|
|
|
let elapsed = -startDelay
|
|
|
|
|
tear.onUpdate(() => {
|
|
|
|
|
elapsed += k.dt()
|
|
|
|
|
if (elapsed < 0) return
|
|
|
|
|
tear.pos.y = tearBaseY + elapsed * 40 * sz
|
|
|
|
|
tear.opacity = 0.7 - elapsed * 0.8
|
|
|
|
|
if (tear.opacity <= 0 && tear.exists()) tear.destroy()
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(tear)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Quivering lower lip
|
|
|
|
|
const lip = k.add([
|
|
|
|
|
k.rect(10 * sz, 2 * sz, { radius: sz }), k.pos(cx - 5 * sz, cy - 16 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#cc4466')), k.opacity(0.6), k.z(34),
|
|
|
|
|
])
|
|
|
|
|
lip.onUpdate(() => { lip.pos.y = cy - 16 * sz + Math.sin(k.time() * 20) * 0.5 * sz })
|
|
|
|
|
grotesqueObjects.push(lip)
|
|
|
|
|
} else if (expression === 4) {
|
|
|
|
|
// SCARED FACE — wide eyes (pupils tiny), mouth agape
|
|
|
|
|
// Shrink pupils
|
|
|
|
|
pupil.onUpdate(() => {
|
|
|
|
|
pupil.pos.x = eyeX + Math.sin(k.time() * 20) * 2 * sz
|
|
|
|
|
pupil.pos.y = eyeY + Math.cos(k.time() * 18) * 1.5 * sz
|
|
|
|
|
})
|
|
|
|
|
// Giant open mouth
|
|
|
|
|
const mouth = k.add([
|
|
|
|
|
k.circle(6 * sz), k.pos(cx, cy - 16 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#110000')), k.opacity(0.7), k.z(33),
|
|
|
|
|
k.scale(1),
|
|
|
|
|
])
|
|
|
|
|
mouth.onUpdate(() => {
|
|
|
|
|
const pulse = 1 + Math.sin(k.time() * 10) * 0.15
|
|
|
|
|
mouth.scale.x = pulse; mouth.scale.y = pulse * 0.7
|
|
|
|
|
})
|
|
|
|
|
grotesqueObjects.push(mouth)
|
|
|
|
|
} else {
|
|
|
|
|
// SMUG GRIN — half-lidded eyes, wide smirk
|
|
|
|
|
// Eyelids (half cover the eyes)
|
|
|
|
|
const lidL = k.add([
|
|
|
|
|
k.rect(16 * sz, 5 * sz), k.pos(eyeX - 8 * sz, eyeY - 6 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#886644')), k.opacity(0.5), k.z(35),
|
|
|
|
|
])
|
|
|
|
|
const lidR = k.add([
|
|
|
|
|
k.rect(12 * sz, 4 * sz), k.pos(eye2X - 6 * sz, eye2Y - 5 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#886644')), k.opacity(0.5), k.z(35),
|
|
|
|
|
])
|
|
|
|
|
// Wide smirk
|
|
|
|
|
const smirk = k.add([
|
|
|
|
|
k.rect(14 * sz, 2 * sz, { radius: sz }), k.pos(cx - 3 * sz * dir, cy - 19 * sz),
|
|
|
|
|
k.color(k.Color.fromHex('#cc3344')), k.opacity(0.7), k.z(34),
|
|
|
|
|
k.rotate(dir > 0 ? 10 : -10),
|
|
|
|
|
])
|
|
|
|
|
grotesqueObjects.push(lidL, lidR, smirk)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function destroyGrotesqueDetails() {
|
|
|
|
|
for (const obj of grotesqueObjects) {
|
|
|
|
|
if (obj.exists()) obj.destroy()
|
|
|
|
|
}
|
|
|
|
|
grotesqueObjects = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Arena-specific background decoration
|
|
|
|
|
function drawArenaDecor() {
|
|
|
|
|
const a = arena
|
|
|
|
|
const acc = theme.accent
|
|
|
|
|
|
|
|
|
|
// Parallax stars/particles in the sky
|
|
|
|
|
for (let i = 0; i < 30; 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(k.Color.fromHex(acc)),
|
|
|
|
|
k.opacity(0.1 + Math.random() * 0.2),
|
|
|
|
|
k.z(1),
|
|
|
|
|
])
|
|
|
|
|
// Twinkle
|
|
|
|
|
const speed = 0.5 + Math.random() * 1.5
|
|
|
|
|
const baseOp = star.opacity
|
|
|
|
|
star.onUpdate(() => {
|
|
|
|
|
star.opacity = baseOp + Math.sin(k.time() * speed + i) * 0.1
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (a === 'datacenter' || a === 'localhost') {
|
|
|
|
|
// Blinking server rack lights
|
|
|
|
|
for (let row = 0; row < 3; row++) {
|
|
|
|
|
for (let col = 0; col < 8; col++) {
|
|
|
|
|
const lx = 30 + col * 95
|
|
|
|
|
const ly = 40 + row * 60
|
|
|
|
|
// Rack body
|
|
|
|
|
k.add([k.rect(70, 50), k.pos(lx, ly), k.color(k.Color.fromHex('#0a0a15')), k.opacity(0.5), k.z(1)])
|
|
|
|
|
// Blinking LED
|
|
|
|
|
const led = k.add([
|
|
|
|
|
k.rect(4, 4), k.pos(lx + 5 + col * 3, ly + 10 + row * 8),
|
|
|
|
|
k.color(k.Color.fromHex(Math.random() > 0.5 ? '#00ff41' : '#ff2d2d')),
|
|
|
|
|
k.opacity(0.6), k.z(2),
|
|
|
|
|
])
|
|
|
|
|
led.onUpdate(() => { led.opacity = Math.random() > 0.95 ? 0.1 : 0.6 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'gpu_graveyard') {
|
|
|
|
|
// Floating circuit board fragments
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
const cx = Math.random() * W
|
|
|
|
|
const cy = 20 + Math.random() * (GROUND_Y - 60)
|
|
|
|
|
const chip = k.add([
|
|
|
|
|
k.rect(15 + Math.random() * 20, 10 + Math.random() * 15),
|
|
|
|
|
k.pos(cx, cy),
|
|
|
|
|
k.color(k.Color.fromHex('#1a2a1a')),
|
|
|
|
|
k.opacity(0.3),
|
|
|
|
|
k.z(1),
|
|
|
|
|
k.rotate(Math.random() * 360),
|
|
|
|
|
])
|
|
|
|
|
// Trace lines
|
|
|
|
|
k.add([k.rect(Math.random() * 30, 1), k.pos(cx, cy + 5), k.color(k.Color.fromHex('#76b900')), k.opacity(0.15), k.z(1)])
|
|
|
|
|
const baseY = cy
|
|
|
|
|
chip.onUpdate(() => { chip.pos.y = baseY + Math.sin(k.time() * 0.3 + i) * 5 })
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'prompt_dungeon') {
|
|
|
|
|
// Glowing runes on the walls
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
const rx = 40 + i * (W / 8)
|
|
|
|
|
const ry = 30 + Math.random() * 80
|
|
|
|
|
const rune = k.add([
|
|
|
|
|
k.circle(8 + Math.random() * 6),
|
|
|
|
|
k.pos(rx, ry),
|
|
|
|
|
k.color(k.Color.fromHex('#b83dff')),
|
|
|
|
|
k.opacity(0.15),
|
|
|
|
|
k.z(1),
|
|
|
|
|
])
|
|
|
|
|
rune.onUpdate(() => { rune.opacity = 0.1 + Math.sin(k.time() * 0.8 + i * 0.7) * 0.1 })
|
|
|
|
|
}
|
|
|
|
|
// Fog at ground level
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const fog = k.add([
|
|
|
|
|
k.circle(40 + Math.random() * 30),
|
|
|
|
|
k.pos(Math.random() * W, GROUND_Y - 10),
|
|
|
|
|
k.color(k.Color.fromHex('#1f1a2a')),
|
|
|
|
|
k.opacity(0.3),
|
|
|
|
|
k.z(3),
|
|
|
|
|
])
|
|
|
|
|
const baseX = fog.pos.x
|
|
|
|
|
fog.onUpdate(() => { fog.pos.x = baseX + Math.sin(k.time() * 0.2 + i) * 20 })
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'the_cloud') {
|
|
|
|
|
// Floating cloud shapes
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
const cx = Math.random() * W
|
|
|
|
|
const cy = 30 + Math.random() * 80
|
|
|
|
|
for (let j = 0; j < 3; j++) {
|
|
|
|
|
const cloud = k.add([
|
|
|
|
|
k.circle(20 + Math.random() * 15),
|
|
|
|
|
k.pos(cx + j * 18, cy + (Math.random() - 0.5) * 10),
|
|
|
|
|
k.color(k.Color.fromHex('#1a1f2a')),
|
|
|
|
|
k.opacity(0.4),
|
|
|
|
|
k.z(1),
|
|
|
|
|
])
|
|
|
|
|
const bx = cloud.pos.x
|
|
|
|
|
cloud.onUpdate(() => { cloud.pos.x = bx + Math.sin(k.time() * 0.15 + i) * 10 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'the_singularity') {
|
|
|
|
|
// Swirling vortex in the background
|
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
|
|
|
const angle = (i / 20) * Math.PI * 2
|
|
|
|
|
const dist = 40 + i * 8
|
|
|
|
|
const sx = W / 2 + Math.cos(angle) * dist
|
|
|
|
|
const sy = GROUND_Y * 0.4 + Math.sin(angle) * dist * 0.5
|
|
|
|
|
const dot = k.add([
|
|
|
|
|
k.circle(2 + Math.random() * 3),
|
|
|
|
|
k.pos(sx, sy),
|
|
|
|
|
k.color(k.Color.fromHex('#ff00ff')),
|
|
|
|
|
k.opacity(0.2),
|
|
|
|
|
k.z(1),
|
|
|
|
|
])
|
|
|
|
|
dot.onUpdate(() => {
|
|
|
|
|
const a2 = angle + k.time() * 0.5
|
|
|
|
|
const d2 = dist + Math.sin(k.time() + i) * 10
|
|
|
|
|
dot.pos.x = W / 2 + Math.cos(a2) * d2
|
|
|
|
|
dot.pos.y = GROUND_Y * 0.4 + Math.sin(a2) * d2 * 0.5
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'silicon_valley_dojo') {
|
|
|
|
|
// Bamboo/pillars on sides
|
|
|
|
|
for (const side of [0.05, 0.1, 0.88, 0.93]) {
|
|
|
|
|
const px = W * side
|
|
|
|
|
k.add([k.rect(6, GROUND_Y - 20), k.pos(px, 20), k.color(k.Color.fromHex('#1a3a1a')), k.opacity(0.4), k.z(1)])
|
|
|
|
|
// Leaves
|
|
|
|
|
for (let j = 0; j < 3; j++) {
|
|
|
|
|
k.add([
|
|
|
|
|
k.circle(8),
|
|
|
|
|
k.pos(px + (Math.random() - 0.5) * 20, 30 + j * 40),
|
|
|
|
|
k.color(k.Color.fromHex('#00ff41')),
|
|
|
|
|
k.opacity(0.15),
|
|
|
|
|
k.z(1),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'hacker_news' || a === 'stackoverflow_ruins') {
|
|
|
|
|
// Floating text-like blocks (simulating code/posts)
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
const bw = 30 + Math.random() * 50
|
|
|
|
|
k.add([
|
|
|
|
|
k.rect(bw, 4 + Math.random() * 3),
|
|
|
|
|
k.pos(20 + Math.random() * (W - 80), 20 + Math.random() * (GROUND_Y - 60)),
|
|
|
|
|
k.color(k.Color.fromHex(acc)),
|
|
|
|
|
k.opacity(0.06 + Math.random() * 0.06),
|
|
|
|
|
k.z(1),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'beach') {
|
|
|
|
|
// Ocean waves at horizon, sun, palm trees
|
|
|
|
|
const sun = k.add([
|
|
|
|
|
k.circle(25), k.pos(W * 0.8, 40),
|
|
|
|
|
k.color(k.Color.fromHex('#ffcc00')), k.opacity(0.5), k.z(1),
|
|
|
|
|
])
|
|
|
|
|
sun.onUpdate(() => { sun.opacity = 0.4 + Math.sin(k.time() * 0.5) * 0.1 })
|
|
|
|
|
// Waves
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const wave = k.add([
|
|
|
|
|
k.rect(W * 0.4, 3), k.pos(Math.random() * W, GROUND_Y - 15 - i * 6),
|
|
|
|
|
k.color(k.Color.fromHex('#2266aa')), k.opacity(0.2), k.z(2),
|
|
|
|
|
])
|
|
|
|
|
const baseX = wave.pos.x
|
|
|
|
|
wave.onUpdate(() => { wave.pos.x = baseX + Math.sin(k.time() * 0.8 + i) * 20 })
|
|
|
|
|
}
|
|
|
|
|
// Palm tree silhouettes
|
|
|
|
|
for (const px of [W * 0.08, W * 0.92]) {
|
|
|
|
|
k.add([k.rect(6, 80), k.pos(px, GROUND_Y - 80), k.color(k.Color.fromHex('#2a1a00')), k.opacity(0.4), k.z(1)])
|
|
|
|
|
for (let l = 0; l < 4; l++) {
|
|
|
|
|
k.add([
|
|
|
|
|
k.rect(30, 4), k.pos(px - 15, GROUND_Y - 82 - l * 3),
|
|
|
|
|
k.color(k.Color.fromHex('#1a5500')), k.opacity(0.3), k.z(1),
|
|
|
|
|
k.rotate(-30 + l * 20),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'desert') {
|
|
|
|
|
// Sand dunes, cacti, heat shimmer
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const dx = W * (0.2 + i * 0.3)
|
|
|
|
|
k.add([k.circle(60 + i * 20), k.pos(dx, GROUND_Y + 10), k.color(k.Color.fromHex('#886630')), k.opacity(0.3), k.z(1)])
|
|
|
|
|
}
|
|
|
|
|
// Cacti
|
|
|
|
|
for (const cx of [W * 0.12, W * 0.55, W * 0.88]) {
|
|
|
|
|
k.add([k.rect(8, 40), k.pos(cx, GROUND_Y - 40), k.color(k.Color.fromHex('#226622')), k.opacity(0.4), k.z(1)])
|
|
|
|
|
k.add([k.rect(15, 6), k.pos(cx - 8, GROUND_Y - 55), k.color(k.Color.fromHex('#226622')), k.opacity(0.35), k.z(1)])
|
|
|
|
|
}
|
|
|
|
|
// Heat shimmer
|
|
|
|
|
const shimmer = k.add([k.rect(W, 2), k.pos(0, GROUND_Y - 5), k.color(k.Color.fromHex('#ff8800')), k.opacity(0.08), k.z(2)])
|
|
|
|
|
shimmer.onUpdate(() => { shimmer.opacity = 0.05 + Math.sin(k.time() * 3) * 0.04 })
|
|
|
|
|
} else if (a === 'forest') {
|
|
|
|
|
// Trees, undergrowth, fireflies
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
const tx = 20 + i * (W / 8)
|
|
|
|
|
const th = 50 + Math.random() * 40
|
|
|
|
|
k.add([k.rect(8, th), k.pos(tx, GROUND_Y - th), k.color(k.Color.fromHex('#1a2a10')), k.opacity(0.4), k.z(1)])
|
|
|
|
|
k.add([k.circle(20 + Math.random() * 15), k.pos(tx, GROUND_Y - th - 10), k.color(k.Color.fromHex('#1a3a10')), k.opacity(0.3), k.z(1)])
|
|
|
|
|
}
|
|
|
|
|
// Fireflies
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
const ff = k.add([
|
|
|
|
|
k.circle(2), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 50)),
|
|
|
|
|
k.color(k.Color.fromHex('#aaff44')), k.opacity(0), k.z(2),
|
|
|
|
|
])
|
|
|
|
|
ff.onUpdate(() => {
|
|
|
|
|
ff.opacity = Math.max(0, Math.sin(k.time() * 2 + i * 1.5) * 0.4)
|
|
|
|
|
ff.pos.x += Math.sin(k.time() + i) * 10 * k.dt()
|
|
|
|
|
ff.pos.y += Math.cos(k.time() * 0.8 + i) * 8 * k.dt()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'jungle') {
|
|
|
|
|
// Dense vines, hanging leaves, misty
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const vx = 30 + i * (W / 6)
|
|
|
|
|
const vineLen = 40 + Math.random() * 60
|
|
|
|
|
k.add([k.rect(2, vineLen), k.pos(vx, 0), k.color(k.Color.fromHex('#1a4a10')), k.opacity(0.3), k.z(1)])
|
|
|
|
|
// Leaf at bottom
|
|
|
|
|
k.add([k.circle(6), k.pos(vx, vineLen), k.color(k.Color.fromHex('#22aa22')), k.opacity(0.25), k.z(1)])
|
|
|
|
|
}
|
|
|
|
|
// Jungle mist
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
const mist = k.add([
|
|
|
|
|
k.circle(50 + Math.random() * 30), k.pos(Math.random() * W, GROUND_Y - 20),
|
|
|
|
|
k.color(k.Color.fromHex('#1a3818')), k.opacity(0.2), k.z(3),
|
|
|
|
|
])
|
|
|
|
|
const bx = mist.pos.x
|
|
|
|
|
mist.onUpdate(() => { mist.pos.x = bx + Math.sin(k.time() * 0.15 + i) * 15 })
|
|
|
|
|
}
|
|
|
|
|
} else if (a === 'outer_space') {
|
|
|
|
|
// Deep star field, nebula, floating asteroids
|
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
|
|
|
const star = k.add([
|
|
|
|
|
k.circle(0.5 + Math.random() * 1.5),
|
|
|
|
|
k.pos(Math.random() * W, Math.random() * H),
|
|
|
|
|
k.color(k.Color.fromHex(Math.random() > 0.8 ? '#aaaaff' : '#ffffff')),
|
|
|
|
|
k.opacity(0.2 + Math.random() * 0.4), k.z(1),
|
|
|
|
|
])
|
|
|
|
|
star.onUpdate(() => { star.opacity = 0.15 + Math.sin(k.time() * (1 + Math.random()) + i) * 0.15 })
|
|
|
|
|
}
|
|
|
|
|
// Nebula glow
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
k.add([
|
|
|
|
|
k.circle(60 + Math.random() * 40),
|
|
|
|
|
k.pos(W * (0.2 + i * 0.3), H * 0.3 + Math.random() * 50),
|
|
|
|
|
k.color(k.Color.fromHex(['#4400aa', '#aa0066', '#0044aa'][i])),
|
|
|
|
|
k.opacity(0.08), k.z(1),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
// Asteroids
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
const ast = k.add([
|
|
|
|
|
k.circle(5 + Math.random() * 8),
|
|
|
|
|
k.pos(Math.random() * W, 20 + Math.random() * (GROUND_Y - 40)),
|
|
|
|
|
k.color(k.Color.fromHex('#444444')), k.opacity(0.3), k.z(1),
|
|
|
|
|
])
|
|
|
|
|
const baseX = ast.pos.x, baseY = ast.pos.y
|
|
|
|
|
ast.onUpdate(() => {
|
|
|
|
|
ast.pos.x = baseX + Math.sin(k.time() * 0.2 + i * 2) * 15
|
|
|
|
|
ast.pos.y = baseY + Math.cos(k.time() * 0.15 + i) * 10
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === SPECTATORS ===
|
|
|
|
|
// Small crowd figures behind the fighters, animated with idle bobbing and reactions
|
|
|
|
|
const spectatorCount = 8 + Math.floor(Math.random() * 6)
|
|
|
|
|
const spectatorColors = ['#cc4444', '#44cc44', '#4444cc', '#cccc44', '#cc44cc', '#44cccc', '#ff8844', '#8844ff']
|
|
|
|
|
for (let i = 0; i < spectatorCount; i++) {
|
|
|
|
|
// Position along the back, between the sides of the arena
|
|
|
|
|
const sx = 30 + (i / (spectatorCount - 1)) * (W - 60)
|
|
|
|
|
const sy = GROUND_Y - 5 + (Math.random() > 0.5 ? -8 : 0) // slight row variation
|
|
|
|
|
const bodyColor = spectatorColors[i % spectatorColors.length]
|
|
|
|
|
const bodyH = 8 + Math.random() * 4
|
|
|
|
|
// Body
|
|
|
|
|
const body = k.add([
|
|
|
|
|
k.rect(5, bodyH), k.pos(sx, sy - bodyH),
|
|
|
|
|
k.color(k.Color.fromHex(bodyColor)), k.opacity(0.3), k.z(5),
|
|
|
|
|
])
|
|
|
|
|
// Head
|
|
|
|
|
const head = k.add([
|
|
|
|
|
k.circle(3), k.pos(sx + 2.5, sy - bodyH - 3),
|
|
|
|
|
k.color(k.Color.fromHex('#ddbb88')), k.opacity(0.3), k.z(5),
|
|
|
|
|
])
|
|
|
|
|
// Idle bobbing
|
|
|
|
|
const baseY = body.pos.y
|
|
|
|
|
const headBaseY = head.pos.y
|
|
|
|
|
const bobSpeed = 1.5 + Math.random() * 2
|
|
|
|
|
const bobAmt = 1 + Math.random() * 2
|
|
|
|
|
body.onUpdate(() => {
|
|
|
|
|
body.pos.y = baseY + Math.sin(k.time() * bobSpeed + i * 0.7) * bobAmt
|
|
|
|
|
})
|
|
|
|
|
head.onUpdate(() => {
|
|
|
|
|
head.pos.y = headBaseY + Math.sin(k.time() * bobSpeed + i * 0.7) * bobAmt
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
k.scene('fight', () => {
|
2026-03-06 22:13:19 +00:00
|
|
|
// Ground with gradient effect (multiple layers)
|
2026-03-06 16:27:54 +00:00
|
|
|
k.add([k.rect(W, H * 0.25), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.ground))])
|
2026-03-06 22:13:19 +00:00
|
|
|
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.6)])
|
|
|
|
|
// Ground gradient lines
|
|
|
|
|
for (let i = 1; i <= 8; i++) {
|
|
|
|
|
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + i * 8), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.08 - i * 0.008)])
|
|
|
|
|
}
|
|
|
|
|
// Perspective grid
|
|
|
|
|
for (let i = 0; i < 32; i++) {
|
|
|
|
|
const x = i * (W / 32)
|
|
|
|
|
// Lines converge toward horizon for depth
|
|
|
|
|
k.add([k.rect(1, H * 0.25), k.pos(x, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.04)])
|
|
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// Arena-specific decorations
|
|
|
|
|
drawArenaDecor()
|
|
|
|
|
|
|
|
|
|
// Umpire chair (center-back, behind fighters)
|
|
|
|
|
const CHAIR_X = W / 2
|
|
|
|
|
const CHAIR_SEAT_Y = GROUND_Y * 0.38
|
|
|
|
|
k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X - 22, CHAIR_SEAT_Y - 8), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)])
|
|
|
|
|
k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X + 18, CHAIR_SEAT_Y - 8), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)])
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const rungY = CHAIR_SEAT_Y + 15 + i * ((GROUND_Y - CHAIR_SEAT_Y - 15) / 6)
|
|
|
|
|
k.add([k.rect(44, 3), k.pos(CHAIR_X - 22, rungY), k.color(k.Color.fromHex('#443322')), k.opacity(0.6), k.z(3)])
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
k.add([k.rect(54, 5), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(k.Color.fromHex('#665544')), k.opacity(0.8), k.z(3)])
|
|
|
|
|
k.add([k.rect(54, 1), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.3), k.z(3)])
|
|
|
|
|
k.add([k.rect(48, 4), k.pos(CHAIR_X - 24, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#665544')), k.opacity(0.8), k.z(3)])
|
|
|
|
|
k.add([k.rect(3, 12), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)])
|
|
|
|
|
k.add([k.rect(3, 12), k.pos(CHAIR_X + 24, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#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),
|
|
|
|
|
k.z(4),
|
|
|
|
|
k.opacity(0.9),
|
|
|
|
|
'judge',
|
|
|
|
|
])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
const scaleA = 1.8 + botA.tier * 0.4
|
2026-03-06 22:13:19 +00:00
|
|
|
k.add([k.sprite('botA', { anim: 'idle' }), k.pos(HOME_A, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), 'fighterA'])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
const scaleB = 1.8 + botB.tier * 0.4
|
2026-03-06 22:13:19 +00:00
|
|
|
k.add([k.sprite('botB', { anim: 'idle' }), k.pos(HOME_B, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), 'fighterB'])
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
config.onReady?.()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
k.go('fight')
|
|
|
|
|
|
|
|
|
|
let comboA = 0
|
|
|
|
|
let comboB = 0
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// === CHOREOGRAPHY FUNCTIONS ===
|
|
|
|
|
// Each returns a Promise and handles all movement + animation
|
|
|
|
|
|
|
|
|
|
async function dashPunch(atk: any, def: any, 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() }
|
|
|
|
|
// 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: any, def: any, 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() } else { sfxPunch() }
|
|
|
|
|
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: any, def: any, 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') } else { sfxPunch() }
|
|
|
|
|
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: any, def: any, 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')
|
|
|
|
|
if (isCritical) screenFlash('#00f0ff')
|
|
|
|
|
// 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: any, def: any, 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() }
|
|
|
|
|
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: any, def: any, 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: any, def: any, 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: any, def: any, 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: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
|
|
|
// Attacker pulls out a "gun" (just plays special anim)
|
|
|
|
|
atk.play('special')
|
|
|
|
|
await k.wait(0.2)
|
|
|
|
|
const bulletCount = isCritical ? 8 : 4
|
|
|
|
|
const gunX = atk.pos.x + dir * 25
|
|
|
|
|
const gunY = atk.pos.y - 40
|
|
|
|
|
// Fire bullets in rapid succession
|
|
|
|
|
for (let i = 0; i < bulletCount; i++) {
|
|
|
|
|
sfxGunshot()
|
|
|
|
|
const targetY = def.pos.y - 20 - Math.random() * 50
|
|
|
|
|
spawnBullet(gunX, gunY, def.pos.x, targetY)
|
|
|
|
|
// Small recoil
|
|
|
|
|
atk.pos.x -= dir * 3
|
|
|
|
|
await k.wait(0.06)
|
|
|
|
|
atk.pos.x += dir * 3
|
|
|
|
|
sfxBulletHit()
|
|
|
|
|
// Flash defender
|
|
|
|
|
def.opacity = 0.4
|
|
|
|
|
await k.wait(0.02)
|
|
|
|
|
def.opacity = 1
|
|
|
|
|
}
|
|
|
|
|
// Show bullet holes on defender side
|
|
|
|
|
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: any, def: any, 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: any, def: any, 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: any, def: any, 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(k.Color.fromHex(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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex(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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex(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(k.Color.fromHex('#888888')),
|
|
|
|
|
k.z(8),
|
|
|
|
|
])
|
|
|
|
|
const jaw1 = k.add([
|
|
|
|
|
k.rect(30, 4),
|
|
|
|
|
k.pos(def.pos.x - 15, GROUND_Y - 16),
|
|
|
|
|
k.color(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex(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(k.Color.fromHex(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),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// INVISIBLE SPEED: so fast you can barely see — just blur lines and impact
|
|
|
|
|
async function lightningRush(atk: any, def: any, 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(k.Color.fromHex(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: any, def: any, 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: any, def: any, 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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex(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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex(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: any, def: any, 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(k.Color.fromHex('#333333')), k.z(17)])
|
|
|
|
|
const fuse = k.add([k.rect(2, 8), k.pos(bombX, bombY - 8), k.color(k.Color.fromHex('#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: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
|
|
|
|
|
atk.play('special')
|
|
|
|
|
await k.wait(0.1)
|
|
|
|
|
// Spin-up sound
|
|
|
|
|
sfxSpecial()
|
|
|
|
|
const bulletCount = isCritical ? 16 : 10
|
|
|
|
|
const gunX = atk.pos.x + dir * 25
|
|
|
|
|
const gunY = atk.pos.y - 38
|
|
|
|
|
for (let i = 0; i < bulletCount; i++) {
|
|
|
|
|
sfxGunshot()
|
|
|
|
|
// Bullet
|
|
|
|
|
const spread = (Math.random() - 0.5) * 50
|
|
|
|
|
spawnBullet(gunX, gunY, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread)
|
|
|
|
|
// Shell casing ejection (tiny gold rect flying up)
|
|
|
|
|
const casing = k.add([k.rect(3, 2), k.pos(gunX - dir * 5, gunY - 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 shake
|
|
|
|
|
atk.pos.x -= dir * 2
|
|
|
|
|
await k.wait(0.01)
|
|
|
|
|
atk.pos.x += dir * 2
|
|
|
|
|
// Flash defender
|
|
|
|
|
if (i % 2 === 0) { def.opacity = 0.3; await k.wait(0.01); def.opacity = 1 }
|
|
|
|
|
sfxBulletHit()
|
|
|
|
|
await k.wait(0.04)
|
|
|
|
|
}
|
|
|
|
|
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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex(isCritical ? '#ff2d2d' : '#4488ff')), k.z(16)])
|
|
|
|
|
const wheelF = k.add([k.circle(8), k.pos(startX + dir * 18, bikeY + 12), k.color(k.Color.fromHex('#333333')), k.z(16)])
|
|
|
|
|
const wheelR = k.add([k.circle(8), k.pos(startX - dir * 16, bikeY + 12), k.color(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex(isCritical ? '#ffd700' : '#ff6600')), k.z(16)])
|
|
|
|
|
// Roof
|
|
|
|
|
const roof = k.add([k.rect(35, 15), k.pos(startX + dir * 8, carY - 15), k.color(k.Color.fromHex(isCritical ? '#ccaa00' : '#cc5500')), k.z(16)])
|
|
|
|
|
// Wheels
|
|
|
|
|
const w1 = k.add([k.circle(8), k.pos(startX + dir * 22, carY + 18), k.color(k.Color.fromHex('#222222')), k.z(16)])
|
|
|
|
|
const w2 = k.add([k.circle(8), k.pos(startX - dir * 22, carY + 18), k.color(k.Color.fromHex('#222222')), k.z(16)])
|
|
|
|
|
// Headlight
|
|
|
|
|
const hl = k.add([k.rect(4, 6), k.pos(startX + dir * 35, carY + 3), k.color(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#8B4513')), k.z(8)])
|
|
|
|
|
const mast = k.add([k.rect(3, 40), k.pos(boatX + 10, boatY - 40), k.color(k.Color.fromHex('#8B4513')), k.z(8)])
|
|
|
|
|
const sail = k.add([k.rect(25, 30), k.pos(boatX + 15, boatY - 45), k.color(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex('#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(k.Color.fromHex('#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: any, def: any, 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: any, def: any, 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: any, def: any, 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: any, def: any, 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),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// CHOREOGRAPHY FACTORIES — generate moves from templates
|
|
|
|
|
// ============================================================
|
|
|
|
|
type ChoreoFn = typeof dashPunch
|
|
|
|
|
|
|
|
|
|
// FACTORY 1: Throw object(s) in an arc at the opponent
|
|
|
|
|
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
|
|
|
|
|
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.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 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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex('#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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex('#222222')), k.z(16)])
|
|
|
|
|
const w2 = k.add([k.circle(wheelR), k.pos(startX - dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(k.Color.fromHex('#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(k.Color.fromHex(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(k.Color.fromHex('#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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex(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(k.Color.fromHex(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) ---
|
|
|
|
|
const pizzaSlam = makeThrow('#ff8800', 16, 2, 70, false, '#ffcc00')
|
|
|
|
|
const bananaFling = makeThrow('#ffe135', 12, 3, 55, false, '#ffff00')
|
|
|
|
|
const pieSmash = makeThrow('#f5deb3', 18, 1, 80, true, '#ffffff')
|
|
|
|
|
const hotdogWhip = makeThrow('#cc6633', 14, 2, 45, false, '#ff6644')
|
|
|
|
|
const watermelonBomb = makeThrow('#33aa33', 20, 1, 90, true, '#ff4466')
|
|
|
|
|
const sushiBarrage = makeThrow('#ffffff', 10, 5, 40, false, '#ff6666')
|
|
|
|
|
const burgerToss = makeThrow('#cc8833', 16, 2, 65, false, '#ffaa00')
|
|
|
|
|
const iceCreamFling = makeThrow('#ffccdd', 12, 3, 50, true, '#ff88bb')
|
|
|
|
|
const tacoStorm = makeThrow('#ffcc44', 11, 4, 55, false, '#ff8800')
|
|
|
|
|
const donutBarrage = makeThrow('#ff88cc', 14, 3, 60, true, '#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(k.Color.fromHex('#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(k.Color.fromHex('#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')
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
// --- 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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
return {
|
|
|
|
|
k,
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {},
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) {
|
2026-03-06 16:27:54 +00:00
|
|
|
const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
|
|
|
|
|
const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0]
|
|
|
|
|
if (!attacker || !defender) return
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
const origAX = side === 'a' ? HOME_A : HOME_B
|
|
|
|
|
const origDX = side === 'a' ? HOME_B : HOME_A
|
2026-03-06 16:27:54 +00:00
|
|
|
const direction = side === 'a' ? 1 : -1
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
const fn = choreographyMap[choreographyName] || dashPunch
|
|
|
|
|
await fn(attacker, defender, direction, origAX, origDX, isCritical)
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// 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)
|
2026-03-06 16:27:54 +00:00
|
|
|
attacker.play('idle')
|
|
|
|
|
defender.play('idle')
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async playRound(event: RoundEvent) {
|
|
|
|
|
const aWon = event.winnerId === event.botAId
|
|
|
|
|
const bWon = event.winnerId === event.botBId
|
2026-03-06 22:13:19 +00:00
|
|
|
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
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// Super-speed scales with intensity
|
|
|
|
|
const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0))
|
|
|
|
|
// Always 2-4 exchanges for variety — intensity controls winner dominance, not count
|
|
|
|
|
const exchangeCount = 2 + Math.floor(Math.random() * 3)
|
|
|
|
|
// Hyperdetail scales with intensity
|
|
|
|
|
const hyperDetail = Math.random() < (0.05 + intensity * 0.45)
|
|
|
|
|
const fA = k.get('fighterA')[0]
|
|
|
|
|
const fB = k.get('fighterB')[0]
|
|
|
|
|
const savedScaleAX = fA?.scale.x
|
|
|
|
|
const savedScaleAY = fA?.scale.y
|
|
|
|
|
const savedScaleBX = fB?.scale.x
|
|
|
|
|
const savedScaleBY = fB?.scale.y
|
|
|
|
|
|
|
|
|
|
// 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(k.Color.fromHex(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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 silly sound on non-decisive hits
|
|
|
|
|
if (Math.random() < 0.15) sfxRandomSilly()
|
|
|
|
|
} else {
|
|
|
|
|
// Draw: alternate
|
|
|
|
|
attackerSide = ex % 2 === 0 ? 'a' : 'b'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round)
|
|
|
|
|
|
|
|
|
|
if (exchangeCritical && isLastExchange) {
|
|
|
|
|
fanfareCritical()
|
|
|
|
|
// RGB glitch + hyperspeed lines on critical final blow
|
|
|
|
|
glitchRGB(0.3)
|
|
|
|
|
const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0]
|
|
|
|
|
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
|
|
|
|
|
if (!aWon && !bWon && isLastExchange) {
|
|
|
|
|
// Draw: clash in the middle
|
|
|
|
|
const fA = k.get('fighterA')[0]
|
|
|
|
|
const fB = k.get('fighterB')[0]
|
|
|
|
|
if (fA && fB) {
|
|
|
|
|
const midX = W / 2
|
|
|
|
|
sfxClash()
|
|
|
|
|
await Promise.all([
|
|
|
|
|
k.tween(fA.pos.x, midX - 30, 0.15, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
|
|
|
|
|
k.tween(fB.pos.x, midX + 30, 0.15, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
|
|
|
|
|
])
|
|
|
|
|
fA.play('attack'); fB.play('attack')
|
|
|
|
|
await k.wait(0.1)
|
|
|
|
|
fA.play('hit'); fB.play('hit')
|
|
|
|
|
k.shake(8)
|
|
|
|
|
spawnSparks(midX, GROUND_Y - 50, 12, '#ffffff')
|
|
|
|
|
screenFlash('#ffffff', 0.08)
|
|
|
|
|
sfxBonk()
|
|
|
|
|
await k.wait(0.3)
|
|
|
|
|
await Promise.all([
|
|
|
|
|
k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeOutQuad),
|
|
|
|
|
k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeOutQuad),
|
|
|
|
|
])
|
|
|
|
|
fA.play('idle'); fB.play('idle')
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Occasionally the defender dodges (non-final exchanges only)
|
|
|
|
|
if (!isLastExchange && Math.random() < 0.15) {
|
|
|
|
|
await this.playDodge(attackerSide === 'a' ? 'b' : 'a')
|
|
|
|
|
sfxDodge()
|
|
|
|
|
sfxBoing()
|
|
|
|
|
} else {
|
|
|
|
|
await this.playAttack(attackerSide, choreo, exchangeCritical)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Brief pause between exchanges (much shorter in super-speed)
|
|
|
|
|
if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.3 + Math.random() * 0.3)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clean up speed lines
|
|
|
|
|
speedLines.forEach(l => { if (l.exists()) l.destroy() })
|
|
|
|
|
speedLines = []
|
|
|
|
|
|
|
|
|
|
// Clean up grotesque overlays before zooming out
|
|
|
|
|
destroyGrotesqueDetails()
|
|
|
|
|
|
|
|
|
|
// Zoom back out from hyperdetail mode
|
|
|
|
|
if (hyperDetail && fA && fB && savedScaleAX != null && savedScaleBX != null) {
|
|
|
|
|
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') })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update combos
|
2026-03-06 16:27:54 +00:00
|
|
|
if (aWon) {
|
|
|
|
|
comboA++; comboB = 0
|
2026-03-06 22:13:19 +00:00
|
|
|
if (comboA >= 3) fanfareCombo(comboA)
|
2026-03-06 16:27:54 +00:00
|
|
|
} else if (bWon) {
|
|
|
|
|
comboB++; comboA = 0
|
2026-03-06 22:13:19 +00:00
|
|
|
if (comboB >= 3) fanfareCombo(comboB)
|
2026-03-06 16:27:54 +00:00
|
|
|
} else {
|
|
|
|
|
comboA = 0; comboB = 0
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isCritical && (aWon || bWon)) {
|
|
|
|
|
fanfareDevastating()
|
|
|
|
|
// Dimensional shift on devastating rounds (60%)
|
|
|
|
|
if (Math.random() < 0.6) dimensionalShift(0.5)
|
2026-03-06 16:27:54 +00:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
async playTaunt(side: 'a' | 'b') {
|
|
|
|
|
const taunter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
|
|
|
|
|
if (!taunter) return
|
|
|
|
|
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]
|
|
|
|
|
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) {
|
2026-03-06 16:27:54 +00:00
|
|
|
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
|
|
|
|
|
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
|
|
|
|
|
if (!loser || !winner) return
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
const dir = winningSide === 'a' ? 1 : -1
|
|
|
|
|
const loserOrigY = loser.pos.y
|
|
|
|
|
const contactX = loser.pos.x - dir * 45
|
|
|
|
|
const finishStyle = Math.floor(Math.random() * 4)
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
// 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(k.Color.fromHex('#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(k.Color.fromHex('#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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === 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.2)
|
|
|
|
|
loser.play('ko')
|
|
|
|
|
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(); 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)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async playPerfect(winningSide: 'a' | 'b', winnerName: string) {
|
|
|
|
|
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
|
|
|
|
|
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
|
|
|
|
|
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)
|
2026-03-06 16:27:54 +00:00
|
|
|
loser.play('knockback')
|
2026-03-06 22:13:19 +00:00
|
|
|
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')
|
2026-03-06 16:27:54 +00:00
|
|
|
k.shake(20)
|
2026-03-06 22:13:19 +00:00
|
|
|
sfxExplosion()
|
|
|
|
|
sfxBoing()
|
|
|
|
|
await k.wait(0.3)
|
2026-03-06 16:27:54 +00:00
|
|
|
loser.play('ko')
|
2026-03-06 22:13:19 +00:00
|
|
|
|
|
|
|
|
// 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)
|
2026-03-06 16:27:54 +00:00
|
|
|
winner.play('win')
|
2026-03-06 22:13:19 +00:00
|
|
|
setTimeout(() => {
|
|
|
|
|
sfxWin()
|
|
|
|
|
sfxWinAnnounce(winnerName)
|
|
|
|
|
}, 300)
|
|
|
|
|
announce('Perfect!', 0.2, 0.5)
|
|
|
|
|
// 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) sfxRandomSilly()
|
|
|
|
|
}, i * 200)
|
|
|
|
|
}
|
|
|
|
|
await k.wait(0.7)
|
2026-03-06 16:27:54 +00:00
|
|
|
},
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
startMusic() { startMusic() },
|
|
|
|
|
stopMusic() { stopMusic() },
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
destroy() {
|
2026-03-06 22:13:19 +00:00
|
|
|
stopMusic()
|
2026-03-06 16:27:54 +00:00
|
|
|
k.quit()
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type FightSceneController = ReturnType<typeof createFightScene>
|