277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import kaplay from 'kaplay'
|
|||
|
|
import { generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS } from './sprites'
|
||
|
|
|
||
|
|
export interface FightSceneConfig {
|
||
|
|
canvas: HTMLCanvasElement
|
||
|
|
botA: { name: string; seed: string; tier: number }
|
||
|
|
botB: { name: string; seed: string; tier: number }
|
||
|
|
arena: string
|
||
|
|
onReady?: () => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RoundEvent {
|
||
|
|
round: number
|
||
|
|
challengeType: string
|
||
|
|
winnerId: string | null
|
||
|
|
botAId: string
|
||
|
|
botBId: string
|
||
|
|
narration: string
|
||
|
|
isCritical: boolean
|
||
|
|
botAScore: number
|
||
|
|
botBScore: number
|
||
|
|
}
|
||
|
|
|
||
|
|
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' },
|
||
|
|
}
|
||
|
|
|
||
|
|
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 },
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pick a random attack animation based on challenge type
|
||
|
|
function pickAttackAnim(challengeType: string, isCritical: boolean): string {
|
||
|
|
if (isCritical) return 'special'
|
||
|
|
const map: Record<string, string[]> = {
|
||
|
|
speed_blitz: ['attack', 'kick'],
|
||
|
|
riddle: ['attack', 'special'],
|
||
|
|
code_golf: ['special', 'attack'],
|
||
|
|
roast_battle: ['special', 'kick'],
|
||
|
|
hallucination_check: ['attack'],
|
||
|
|
token_economy: ['kick', 'attack'],
|
||
|
|
creative_writing: ['special'],
|
||
|
|
math_blitz: ['attack', 'kick'],
|
||
|
|
trap_card: ['special', 'kick'],
|
||
|
|
}
|
||
|
|
const options = map[challengeType] || ['attack', 'kick']
|
||
|
|
return options[Math.floor(Math.random() * options.length)]
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pick defender reaction
|
||
|
|
function pickDefenderAnim(isCritical: boolean): string {
|
||
|
|
return isCritical ? 'knockback' : 'hit'
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
const sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
||
|
|
const sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
||
|
|
|
||
|
|
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||
|
|
k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||
|
|
|
||
|
|
const W = k.width()
|
||
|
|
const H = k.height()
|
||
|
|
const GROUND_Y = H * 0.78
|
||
|
|
|
||
|
|
k.scene('fight', () => {
|
||
|
|
// Ground
|
||
|
|
k.add([k.rect(W, H * 0.25), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.ground))])
|
||
|
|
k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.5)])
|
||
|
|
|
||
|
|
for (let i = 1; i <= 5; i++) {
|
||
|
|
k.add([k.rect(W, 1), k.pos(0, GROUND_Y + i * 12), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.06)])
|
||
|
|
}
|
||
|
|
for (let i = 0; i < 24; i++) {
|
||
|
|
k.add([k.rect(1, H * 0.25), k.pos(i * (W / 24), GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.04)])
|
||
|
|
}
|
||
|
|
|
||
|
|
const scaleA = 1.8 + botA.tier * 0.4
|
||
|
|
k.add([k.sprite('botA', { anim: 'idle' }), k.pos(W * 0.28, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), 'fighterA'])
|
||
|
|
|
||
|
|
const scaleB = 1.8 + botB.tier * 0.4
|
||
|
|
k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W * 0.72, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), 'fighterB'])
|
||
|
|
|
||
|
|
k.add([k.text('', { size: 42, font: 'monospace' }), k.pos(W / 2, H * 0.3), k.anchor('center'), k.color(k.Color.fromHex('#ffffff')), k.opacity(0), k.z(100), 'announcement'])
|
||
|
|
k.add([k.text('', { size: 32, font: 'monospace' }), k.pos(0, 0), k.anchor('center'), k.color(k.Color.fromHex('#ff2d2d')), k.opacity(0), k.z(90), 'hitText'])
|
||
|
|
k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.28, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboA'])
|
||
|
|
k.add([k.text('', { size: 14, font: 'monospace' }), k.pos(W * 0.72, GROUND_Y + 16), k.anchor('center'), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0), k.z(50), 'comboB'])
|
||
|
|
|
||
|
|
config.onReady?.()
|
||
|
|
})
|
||
|
|
|
||
|
|
k.go('fight')
|
||
|
|
|
||
|
|
let comboA = 0
|
||
|
|
let comboB = 0
|
||
|
|
|
||
|
|
return {
|
||
|
|
k,
|
||
|
|
|
||
|
|
async showAnnouncement(text: string, color: string = '#ffffff', duration: number = 1200) {
|
||
|
|
const ann = k.get('announcement')[0]
|
||
|
|
if (!ann) return
|
||
|
|
ann.text = text
|
||
|
|
ann.color = k.Color.fromHex(color)
|
||
|
|
ann.opacity = 1
|
||
|
|
ann.scaleTo(0.5)
|
||
|
|
await k.tween(ann.scale.x, 1, 0.2, (v) => ann.scaleTo(v), k.easings.easeOutBack)
|
||
|
|
await k.wait(duration / 1000)
|
||
|
|
await k.tween(1, 0, 0.3, (v) => { ann.opacity = v })
|
||
|
|
},
|
||
|
|
|
||
|
|
async playAttack(side: 'a' | 'b', attackAnim: string, defenderAnim: string, isCritical: boolean) {
|
||
|
|
const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
|
||
|
|
const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0]
|
||
|
|
if (!attacker || !defender) return
|
||
|
|
|
||
|
|
const origAX = attacker.pos.x
|
||
|
|
const origDX = defender.pos.x
|
||
|
|
const direction = side === 'a' ? 1 : -1
|
||
|
|
const lunge = attackAnim === 'special' ? 20 : 40 + (isCritical ? 20 : 0)
|
||
|
|
|
||
|
|
// Lunge forward
|
||
|
|
await k.tween(attacker.pos.x, attacker.pos.x + direction * lunge, 0.15, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad)
|
||
|
|
|
||
|
|
attacker.play(attackAnim as any)
|
||
|
|
await k.wait(attackAnim === 'special' ? 0.35 : 0.2)
|
||
|
|
|
||
|
|
defender.play(defenderAnim as any)
|
||
|
|
|
||
|
|
// Hit text
|
||
|
|
const hitFx = k.get('hitText')[0]
|
||
|
|
if (hitFx) {
|
||
|
|
const words = isCritical
|
||
|
|
? ['CRITICAL!', 'DEVASTATING!', 'BRUTAL!', 'OBLITERATED!']
|
||
|
|
: attackAnim === 'kick' ? ['KICK!', 'ROUNDHOUSE!', 'SWEPT!']
|
||
|
|
: attackAnim === 'special' ? ['SPECIAL!', 'HADOUKEN!', 'ZAPPED!']
|
||
|
|
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!']
|
||
|
|
hitFx.text = words[Math.floor(Math.random() * words.length)]
|
||
|
|
hitFx.pos.x = defender.pos.x + (side === 'a' ? -20 : 20)
|
||
|
|
hitFx.pos.y = defender.pos.y - 90
|
||
|
|
hitFx.opacity = 1
|
||
|
|
hitFx.color = isCritical ? k.Color.fromHex('#ffe14d') : attackAnim === 'special' ? k.Color.fromHex('#00f0ff') : k.Color.fromHex('#ff2d2d')
|
||
|
|
k.tween(hitFx.pos.y, hitFx.pos.y - 50, 0.8, (v) => { hitFx.pos.y = v })
|
||
|
|
k.tween(1, 0, 1, (v) => { hitFx.opacity = v })
|
||
|
|
}
|
||
|
|
|
||
|
|
// Screen shake
|
||
|
|
k.shake(isCritical ? 15 : attackAnim === 'special' ? 8 : 5)
|
||
|
|
|
||
|
|
// Knockback — push defender back
|
||
|
|
if (defenderAnim === 'knockback') {
|
||
|
|
const pushDist = direction * -60
|
||
|
|
await k.tween(defender.pos.x, defender.pos.x + pushDist, 0.3, (v) => { defender.pos.x = v }, k.easings.easeOutQuad)
|
||
|
|
await k.wait(0.3)
|
||
|
|
// Return defender
|
||
|
|
await k.tween(defender.pos.x, origDX, 0.4, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad)
|
||
|
|
} else {
|
||
|
|
// Flash defender
|
||
|
|
await k.wait(0.1)
|
||
|
|
defender.opacity = 0.3; await k.wait(0.05)
|
||
|
|
defender.opacity = 1; await k.wait(0.05)
|
||
|
|
defender.opacity = 0.3; await k.wait(0.05)
|
||
|
|
defender.opacity = 1
|
||
|
|
await k.wait(0.2)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Return attacker
|
||
|
|
await k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeInQuad)
|
||
|
|
|
||
|
|
await k.wait(0.2)
|
||
|
|
attacker.play('idle')
|
||
|
|
defender.play('idle')
|
||
|
|
},
|
||
|
|
|
||
|
|
async playRound(event: RoundEvent) {
|
||
|
|
const aWon = event.winnerId === event.botAId
|
||
|
|
const bWon = event.winnerId === event.botBId
|
||
|
|
const isCritical = Math.abs(event.botAScore - event.botBScore) > 4
|
||
|
|
const atkAnim = pickAttackAnim(event.challengeType, isCritical)
|
||
|
|
const defAnim = pickDefenderAnim(isCritical)
|
||
|
|
|
||
|
|
if (aWon) {
|
||
|
|
comboA++; comboB = 0
|
||
|
|
await this.playAttack('a', atkAnim, defAnim, isCritical)
|
||
|
|
if (comboA >= 2) {
|
||
|
|
const ct = k.get('comboA')[0]
|
||
|
|
if (ct) { ct.text = `x${comboA} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) }
|
||
|
|
}
|
||
|
|
} else if (bWon) {
|
||
|
|
comboB++; comboA = 0
|
||
|
|
await this.playAttack('b', atkAnim, defAnim, isCritical)
|
||
|
|
if (comboB >= 2) {
|
||
|
|
const ct = k.get('comboB')[0]
|
||
|
|
if (ct) { ct.text = `x${comboB} COMBO!`; ct.opacity = 1; k.tween(1, 0, 1.5, (v) => { ct.opacity = v }) }
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
comboA = 0; comboB = 0
|
||
|
|
// Draw — both take a hit
|
||
|
|
const fA = k.get('fighterA')[0]
|
||
|
|
const fB = k.get('fighterB')[0]
|
||
|
|
if (fA && fB) {
|
||
|
|
fA.play('hit'); fB.play('hit')
|
||
|
|
k.shake(3)
|
||
|
|
await k.wait(0.5)
|
||
|
|
fA.play('idle'); fB.play('idle')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
async playKO(winningSide: 'a' | 'b') {
|
||
|
|
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
|
||
|
|
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
|
||
|
|
if (!loser || !winner) return
|
||
|
|
|
||
|
|
loser.play('knockback')
|
||
|
|
k.shake(20)
|
||
|
|
await k.wait(0.4)
|
||
|
|
loser.play('ko')
|
||
|
|
await k.wait(0.6)
|
||
|
|
await this.showAnnouncement('K.O.!', '#ff2d2d', 2000)
|
||
|
|
winner.play('win')
|
||
|
|
await k.wait(0.5)
|
||
|
|
},
|
||
|
|
|
||
|
|
async playPerfect(winningSide: 'a' | 'b') {
|
||
|
|
const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0]
|
||
|
|
const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0]
|
||
|
|
if (!loser || !winner) return
|
||
|
|
|
||
|
|
loser.play('knockback')
|
||
|
|
k.shake(25)
|
||
|
|
await k.wait(0.5)
|
||
|
|
loser.play('ko')
|
||
|
|
await this.showAnnouncement('PERFECT!', '#ffe14d', 2500)
|
||
|
|
winner.play('win')
|
||
|
|
},
|
||
|
|
|
||
|
|
destroy() {
|
||
|
|
k.quit()
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export type FightSceneController = ReturnType<typeof createFightScene>
|