feat: THE CREATOR god-tier character, bitcoin choreographies, omni-morph, cameos

- Guy Fawkes mask archetype with golden outline, bitcoin chest symbol, laptop, tier-gated effects
- 12 custom bitcoin-themed choreographies (8 regular + 4 exclusive ultimates)
- Omni-morph system: creator morphs into any of 80+ archetypes instead of cycling 3
- 6% per-round cameo in non-creator fights — drops golden ₿ gifts to fighters
- Custom golden code rain entrance with persistent orbiting particles
- pickChoreography: 50% ultimate chance (100% on crits), all tier ultimates + 4 exclusive
- Server auto-assigns the_creator archetype on login/register for creator pubkey
- FightViewer, payments, queue, orchestrator improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 12:08:18 +00:00
co-authored by Claude Opus 4.6
parent 8a3480e5ab
commit 6d390f69b2
14 changed files with 1513 additions and 104 deletions
+91 -23
View File
@@ -8,6 +8,7 @@ import {
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
setMusicIntensity, stopAllAudio,
setMasterMute, isMasterMuted, ensureAudioContext,
speakQuestion, speakAnswer, speakNarration,
} from '../game/sounds'
interface Round {
@@ -66,6 +67,7 @@ const hitTextX = ref(50)
const hitTextY = ref(30)
const glitching = ref(false)
const soundOn = ref(true)
const insanityMode = ref(false)
async function toggleSound() {
soundOn.value = !soundOn.value
@@ -73,6 +75,14 @@ async function toggleSound() {
setMasterMute(!soundOn.value)
}
function toggleInsanity() {
insanityMode.value = !insanityMode.value
// Insanity mode kills all TTS immediately
if (insanityMode.value && typeof speechSynthesis !== 'undefined') {
speechSynthesis.cancel()
}
}
// Staggered log
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
@@ -269,28 +279,72 @@ async function _doReplay() {
for (const round of props.fight.rounds) {
currentRound.value = round.roundNumber
fanfareRound(round.roundNumber)
await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
await sleep(80)
await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
await sleep(80)
fanfareFight()
await showOverlay('FIGHT!', '#ff2d7b', 400)
await sleep(80)
// Show speech bubbles BEFORE the fight animation so viewers see what bots said
if (scene) {
if (round.botAResponse) scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 3.5)
if (round.botBResponse) {
setTimeout(() => {
if (scene && round.botBResponse) scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 3.2)
}, 400)
}
if (insanityMode.value) {
// Insanity: minimal overlays, no fanfares
await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150)
} else {
fanfareRound(round.roundNumber)
await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
await sleep(80)
await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
await sleep(80)
fanfareFight()
await showOverlay('FIGHT!', '#ff2d7b', 400)
await sleep(80)
}
await sleep(300)
// Log + fight animation in parallel
const logPromise = addRoundToLog(round, true)
// === TTS-synced question + answer flow ===
const challenge = JSON.parse(round.challengeData)
const doTTS = soundOn.value && !insanityMode.value
// 1. Show question in log AND speak it
logItems.value.push(
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
)
scrollLog(); await sleep(insanityMode.value ? 30 : 100)
logItems.value.push(
{ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' },
)
scrollLog()
if (doTTS && challenge.prompt) {
await speakQuestion(challenge.prompt)
await sleep(150)
} else {
await sleep(insanityMode.value ? 50 : 600)
}
// 2. Bot A: log + bubble + mouth + TTS (all synced)
if (round.botAResponse) {
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' })
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
scrollLog()
if (scene && !insanityMode.value) {
scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5)
scene.startTalking('a')
}
if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse.slice(0, 60))
else await sleep(insanityMode.value ? 30 : 800)
scene?.stopTalking('a')
if (!insanityMode.value) await sleep(150)
}
// 3. Bot B: log + bubble + mouth + TTS
if (round.botBResponse) {
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' })
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
scrollLog()
if (scene && !insanityMode.value) {
scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5)
scene.startTalking('b')
}
if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse.slice(0, 60))
else await sleep(insanityMode.value ? 30 : 800)
scene?.stopTalking('b')
if (!insanityMode.value) await sleep(150)
}
// Log is already populated above — no need for addRoundToLog stagger
const logPromise = Promise.resolve()
const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
const aWon = round.winnerId === props.fight.botA!.id
const bWon = round.winnerId === props.fight.botB!.id
@@ -328,6 +382,8 @@ async function _doReplay() {
if (round.narration) {
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
scrollLog()
if (doTTS) await speakNarration(round.narration)
await sleep(insanityMode.value ? 30 : 200)
}
const winner = aWon ? props.fight.botA!.name : bWon ? props.fight.botB!.name : 'DRAW'
@@ -352,17 +408,18 @@ async function _doReplay() {
setMusicIntensity(1 - lowestHp / 100)
// Taunting between rounds — winner taunts, sometimes both
if (scene && (aWon || bWon)) {
if (!insanityMode.value && scene && (aWon || bWon)) {
const winnerSide = aWon ? 'a' : 'b'
await sleep(150)
await scene.playTaunt(winnerSide)
// Sometimes loser taunts back (30% chance)
if (Math.random() < 0.3) {
await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a')
}
await sleep(200)
} else {
} else if (!insanityMode.value) {
await sleep(400)
} else {
await sleep(30)
}
}
@@ -593,6 +650,17 @@ async function _doReplay() {
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
<button
v-if="isReplaying"
class="h-8 px-2 flex items-center justify-center border text-[9px] font-pixel tracking-wider transition-all"
:class="insanityMode
? 'border-neon-yellow/70 text-neon-yellow bg-neon-yellow/10 animate-pulse'
: 'border-border/50 text-text-muted hover:text-neon-yellow hover:border-neon-yellow/50'"
title="INSANITY MODE: Skip TTS, max speed"
@click="toggleInsanity"
>
{{ insanityMode ? 'INSANITY' : 'SKIP' }}
</button>
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
</div>
</div>
+774 -51
View File
@@ -107,7 +107,29 @@ const TIER_ULTIMATES: Record<number, string[]> = {
5: ['ultimateArmageddon', 'ultimateOmegaBeam', 'ultimateBeyondTheScreen', 'ultimateMatrixDodge', 'ultimateWorldEnder', 'ultimateAbsoluteZero', 'ultimateSolarCannon', 'ultimatePhantomStrike', 'ultimateGodPunch', 'ultimateVoidCollapse'],
}
function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0): string {
// The Creator — exclusive moves (50% ultimate chance, 100% on crits, custom pool)
const CREATOR_MOVES = ['bitcoinRain', 'lightningInvoice', 'satoshiStrike', 'hashRateOverload', 'blockchainSlam', 'creatorMode', 'morphStrike', 'divineIntervention']
const CREATOR_ULTIMATES = ['ultimateGenesisBlock', 'ultimateTheHalving', 'ultimateFullNodeCrush', 'ultimateSatoshiVision']
function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string {
// THE CREATOR: special move selection — 50% ultimate, 60% creator moves, rest normal
if (attackerArchetype === 'the_creator') {
const ultimateChance = isCritical ? 1.0 : 0.5
if (Math.random() < ultimateChance) {
// Creator gets ALL ultimates + their 4 exclusive ones
const all: string[] = [...CREATOR_ULTIMATES]
for (let t = 2; t <= 5; t++) {
if (TIER_ULTIMATES[t]) all.push(...TIER_ULTIMATES[t])
}
return all[Math.floor(Math.random() * all.length)]
}
// 60% creator-themed, 40% normal selection
if (Math.random() < 0.6) {
return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)]
}
// Fall through to normal selection
}
// Tier-gated ultimates: higher tier = higher chance of spectacular ultimate moves
// Tier 2: 15% chance, Tier 3: 20%, Tier 4: 30%, Tier 5: 40%
if (attackerTier >= 2) {
@@ -636,44 +658,9 @@ export async function createFightScene(config: FightSceneConfig) {
}))
}
// Grotesque close-up: spawn exaggerated impact details during zoom-ins
let grotesqueObjs: any[] = []
function spawnGrotesqueDetails(fighter: any, scaleFactor: number) {
destroyGrotesqueDetails()
if (!fighter?.exists()) return
const fx = fighter.pos.x, fy = fighter.pos.y
const s = Math.max(1, scaleFactor)
// Impact lines radiating from fighter
for (let i = 0; i < 6; i++) {
const angle = (i / 6) * Math.PI * 2 + Math.random() * 0.3
const len = 30 + Math.random() * 40
const line = k.add([
k.rect(3 * s, len * s),
k.pos(fx + Math.cos(angle) * 20, fy - 30 + Math.sin(angle) * 20),
k.color(safeColor(k, i % 2 === 0 ? '#ffffff' : '#ff2d2d')),
k.opacity(0.7),
k.z(25),
k.rotate(angle * (180 / Math.PI) + 90),
k.anchor('center'),
])
grotesqueObjs.push(line)
}
// Speed/impact dots
for (let i = 0; i < 4; i++) {
const dot = k.add([
k.circle(4 + Math.random() * 6),
k.pos(fx + (Math.random() - 0.5) * 60, fy - 30 + (Math.random() - 0.5) * 50),
k.color(safeColor(k, '#ffe14d')),
k.opacity(0.8),
k.z(25),
])
grotesqueObjs.push(dot)
}
}
function destroyGrotesqueDetails() {
for (const o of grotesqueObjs) { if (o.exists()) o.destroy() }
grotesqueObjs = []
}
// Grotesque close-up overlays removed — noops retained for call-site compatibility
function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
function destroyGrotesqueDetails() {}
// Arena-specific background decoration
function drawArenaDecor() {
const a = arena
@@ -5718,6 +5705,453 @@ export async function createFightScene(config: FightSceneConfig) {
// Tier-gated pools (mapping is at module level for pickChoreography access)
// ═══════════════════════════════════════════════════════
// THE CREATOR — Exclusive choreographies (bitcoin-themed)
// ═══════════════════════════════════════════════════════
async function bitcoinRain(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
atk.play('special')
sfxSpecial()
await k.wait(0.2)
// Rain golden ₿ coins from above
const coinCount = isCritical ? 30 : 15
for (let i = 0; i < coinCount; i++) {
const cx = def.pos.x + (Math.random() - 0.5) * 120
const startY = def.pos.y - 200 - Math.random() * 100
const coin = k.add([
k.circle(isCritical ? 5 : 3),
k.pos(cx, startY),
k.color(safeColor(k, Math.random() > 0.3 ? '#ffd700' : '#ffee88')),
k.opacity(0.9),
k.z(18),
])
coin.onUpdate(() => {
coin.pos.y += 600 * k.dt()
if (coin.pos.y > def.pos.y + 10) {
coin.destroy()
}
})
if (i % 4 === 0) sfxCoin()
await k.wait(0.03)
}
await k.wait(0.15)
def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 18 : 8)
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 20 : 10, '#ffd700')
if (isCritical) { screenFlash('#ffd700', 0.12); sfxExplosion() }
const push = dir * (isCritical ? 80 : 35)
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.4)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function lightningInvoice(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
atk.play('special')
sfxZap()
await k.wait(0.15)
// Jagged lightning bolt from top of screen to defender
const segments = 8
const topY = -50
const botY = def.pos.y - 20
for (let i = 0; i < segments; i++) {
const t = i / segments
const bx = def.pos.x + (Math.random() - 0.5) * 40
const by2 = topY + t * (botY - topY)
const bolt = k.add([
k.rect(3, Math.abs(botY - topY) / segments + 5),
k.pos(bx, by2),
k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#ffffff')),
k.opacity(0.95),
k.z(20),
])
setTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300)
}
// ₿ symbol flash at impact
const btcFlash = k.add([
k.circle(isCritical ? 20 : 12),
k.pos(def.pos.x, def.pos.y - 30),
k.color(safeColor(k, '#ffd700')),
k.opacity(0.8),
k.z(22),
])
k.tween(btcFlash.opacity, 0, 0.4, (v: number) => { btcFlash.opacity = v })
.then(() => { if (btcFlash.exists()) btcFlash.destroy() })
await k.wait(0.1)
def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 20 : 10)
sfxExplosion()
spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ffd700')
if (isCritical) { screenFlash('#ffd700'); glitchRGB() }
const push = dir * (isCritical ? 90 : 40)
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.4)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function satoshiStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
atk.play('special')
sfxSpecial()
await k.wait(0.1)
// Rapid-fire gold particle stream
const shotCount = isCritical ? 25 : 15
for (let i = 0; i < shotCount; i++) {
const p = k.add([
k.circle(2),
k.pos(atk.pos.x + dir * 30, atk.pos.y - 40 + (Math.random() - 0.5) * 15),
k.color(safeColor(k, i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')),
k.opacity(0.9),
k.z(16),
])
const speed = 800 + Math.random() * 400
p.onUpdate(() => {
p.pos.x += dir * speed * k.dt()
p.opacity -= 1.5 * k.dt()
if (p.opacity <= 0 || Math.abs(p.pos.x - atk.pos.x) > 500) p.destroy()
})
if (i % 3 === 0) sfxCoin()
if (i % 5 === 0) {
def.play('hit')
k.shake(2)
spawnSparks(def.pos.x, def.pos.y - 25 - Math.random() * 20, 3, '#ffd700')
}
await k.wait(0.025)
}
def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 12 : 5)
if (isCritical) { screenFlash('#ffd700', 0.08); sfxCritical() }
const push = dir * (isCritical ? 70 : 30)
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.3)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function hashRateOverload(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
atk.play('special')
sfxSpecial()
// Screen fills with scrolling hash text (simulated with falling green/gold rects)
const hashCount = isCritical ? 40 : 20
for (let i = 0; i < hashCount; i++) {
const hx = Math.random() * k.width()
const hy = Math.random() * k.height()
const h = k.add([
k.rect(8 + Math.random() * 12, 2),
k.pos(hx, hy),
k.color(safeColor(k, Math.random() > 0.5 ? '#00ff41' : '#ffd700')),
k.opacity(0.6),
k.z(25),
])
h.onUpdate(() => {
h.pos.y += 200 * k.dt()
h.opacity -= 1.5 * k.dt()
if (h.opacity <= 0) h.destroy()
})
}
await k.wait(0.5)
// Explosion
screenFlash(isCritical ? '#ffd700' : '#00ff41', 0.15)
sfxExplosion()
def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 25 : 12)
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 12, '#ffd700')
if (isCritical) glitchRGB()
const push = dir * (isCritical ? 100 : 50)
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.4)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function blockchainSlam(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
const contactX = origDX - dir * 60
sfxZoomWhoosh()
await k.tween(atk.pos.x, contactX, 0.12, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad)
atk.play('attack')
// Chain of blocks swinging as weapon
const blocks: any[] = []
const blockCount = isCritical ? 6 : 4
for (let i = 0; i < blockCount; i++) {
const bk = k.add([
k.rect(10, 10),
k.pos(atk.pos.x + dir * (i * 14), atk.pos.y - 40),
k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#c8a000')),
k.opacity(0.9),
k.z(17),
])
blocks.push(bk)
}
// Swing chain toward defender
for (let i = 0; i < blockCount; i++) {
await k.tween(blocks[i].pos.x, def.pos.x + (i - blockCount / 2) * 5, 0.08, (v: number) => { blocks[i].pos.x = v }, k.easings.easeOutQuad)
await k.tween(blocks[i].pos.y, def.pos.y - 30, 0.04, (v: number) => { blocks[i].pos.y = v })
}
sfxPunch()
def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 15 : 7)
spawnSparks(def.pos.x, def.pos.y - 30, 10, '#ffd700')
if (isCritical) { screenFlash('#ffd700', 0.1); sfxCritical() }
blocks.forEach(b => b.destroy())
const push = dir * (isCritical ? 80 : 35)
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.35)
await Promise.all([
k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
])
atk.play('idle')
}
async function creatorMode(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
atk.play('special')
sfxSpecial()
// Typing effect — rapid small flashes from attacker
for (let i = 0; i < 8; i++) {
const sp = k.add([
k.circle(2),
k.pos(atk.pos.x + dir * 20, atk.pos.y - 45 + (Math.random() - 0.5) * 10),
k.color(safeColor(k, i % 2 === 0 ? '#00ff41' : '#ffd700')),
k.opacity(0.8),
k.z(16),
])
setTimeout(() => { if (sp.exists()) sp.destroy() }, 200)
await k.wait(0.05)
}
// Reality warp — defender's sprite distorts
if (def.exists()) {
const origScaleX = def.scale?.x ?? 1
const origScaleY = def.scale?.y ?? 1
for (let w = 0; w < 4; w++) {
def.scaleTo(origScaleX * (1 + (Math.random() - 0.5) * 0.5), origScaleY * (1 + (Math.random() - 0.5) * 0.5))
await k.wait(0.06)
}
def.scaleTo(origScaleX, origScaleY)
}
sfxExplosion()
def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 18 : 8)
spawnSparks(def.pos.x, def.pos.y - 30, 12, '#00ff41')
if (isCritical) { screenFlash('#00ff41'); glitchRGB() }
const push = dir * (isCritical ? 70 : 30)
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.4)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function morphStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
// Flash golden — the morph is handled by the morph system
// This move itself is a quick dash-hit with gold sparks
screenFlash('#ffd700', 0.08)
sfxZoomWhoosh()
const contactX = origDX - dir * 45
await k.tween(atk.pos.x, contactX, 0.1, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad)
atk.play('attack')
sfxPunch()
await k.wait(0.08)
def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 14 : 6)
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 15 : 8, '#ffd700')
if (isCritical) { sfxCritical(); screenFlash('#ffd700') }
const push = dir * (isCritical ? 80 : 35)
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.3)
await Promise.all([
k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
k.tween(def.pos.x, origDX, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
])
atk.play('idle')
}
async function divineIntervention(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) {
// Ascend off screen
sfxZoomWhoosh()
atk.play('win')
await k.tween(atk.pos.y, atk.pos.y - 300, 0.25, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad)
atk.opacity = 0
// Golden glow expanding where they were
const glowX = origAX
const glow = k.add([
k.circle(5),
k.pos(glowX, GROUND_Y - 40),
k.color(safeColor(k, '#ffd700')),
k.opacity(0.6),
k.z(19),
])
await k.tween(5, 60, 0.3, (v: number) => { glow.radius = v; glow.opacity = 0.6 - v / 150 })
glow.destroy()
// Slam down onto defender from above
atk.opacity = 1
atk.pos.x = def.pos.x
atk.pos.y = -100
atk.play('kick')
sfxZoomWhoosh()
await k.tween(atk.pos.y, GROUND_Y, 0.15, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad)
// Massive impact
sfxExplosion()
k.shake(isCritical ? 30 : 18)
screenFlash('#ffd700', 0.15)
spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 15, '#ffd700')
spawnShockwave(def.pos.x, GROUND_Y, '#ffd700')
if (isCritical) glitchRGB()
def.play('knockback')
const push = dir * (isCritical ? 120 : 60)
k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.5)
await Promise.all([
k.tween(atk.pos.x, origAX, 0.25, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad),
k.tween(atk.pos.y, GROUND_Y, 0.01, (v: number) => { atk.pos.y = v }),
k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad),
])
atk.play('idle')
}
// ── Creator Ultimates ──
async function ultimateGenesisBlock(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
// Screen goes dark, massive genesis block materializes and detonates
atk.play('special')
sfxSpecial()
const darkness = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(30)])
await k.tween(darkness.opacity, 0.7, 0.3, (v: number) => { darkness.opacity = v })
// Genesis block appears at center
const block = k.add([
k.rect(60, 60),
k.pos(k.width() / 2 - 30, k.height() / 2 - 30),
k.color(safeColor(k, '#ffd700')),
k.opacity(0),
k.z(35),
])
await k.tween(block.opacity, 1, 0.3, (v: number) => { block.opacity = v })
await k.wait(0.3)
// Detonate!
sfxExplosion()
block.destroy()
darkness.destroy()
screenFlash('#ffd700', 0.2)
k.shake(35)
glitchRGB()
spawnShockwave(k.width() / 2, k.height() / 2, '#ffd700')
spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700')
def.play('knockback')
const push = dir * 120
k.tween(def.pos.x, origDX + push, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.6)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function ultimateTheHalving(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
// Screen splits horizontally with a golden line
atk.play('special')
sfxZap()
const line = k.add([
k.rect(k.width(), 3),
k.pos(0, k.height() / 2),
k.color(safeColor(k, '#ffd700')),
k.opacity(0),
k.z(32),
])
await k.tween(line.opacity, 1, 0.15, (v: number) => { line.opacity = v })
screenFlash('#ffd700', 0.1)
glitchRGB()
sfxExplosion()
k.shake(25)
// Briefly scale defender to half
if (def.exists()) {
const origSY = def.scale?.y ?? 1
def.scaleTo(def.scale?.x ?? 1, origSY * 0.5)
await k.wait(0.2)
def.scaleTo(def.scale?.x ?? 1, origSY)
}
line.destroy()
def.play('knockback')
spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ffd700')
const push = dir * 100
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.5)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function ultimateFullNodeCrush(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
// Giant server rack drops from above
atk.play('special')
sfxSpecial()
await k.wait(0.3)
const rack = k.add([
k.rect(40, 80),
k.pos(def.pos.x - 20, -100),
k.color(safeColor(k, '#333333')),
k.opacity(0.9),
k.z(28),
])
// LEDs on the rack
for (let led = 0; led < 6; led++) {
k.add([
k.circle(1),
k.pos(rack.pos.x + 5 + led * 5, rack.pos.y + 10),
k.color(safeColor(k, led % 2 === 0 ? '#00ff41' : '#ff0000')),
k.opacity(0.8),
k.z(29),
])
}
sfxZoomWhoosh()
await k.tween(rack.pos.y, def.pos.y - 80, 0.2, (v: number) => { rack.pos.y = v }, k.easings.easeInQuad)
// Impact
sfxExplosion()
k.shake(30)
screenFlash('#ffffff', 0.15)
spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffd700')
spawnShockwave(def.pos.x, GROUND_Y, '#333333')
rack.destroy()
def.play('knockback')
const push = dir * 100
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.6)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
async function ultimateSatoshiVision(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) {
// Everything turns gold — defender trapped in bitcoin block
atk.play('special')
sfxSpecial()
// Gold overlay
const overlay = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(30)])
await k.tween(overlay.opacity, 0.4, 0.3, (v: number) => { overlay.opacity = v })
// Wireframe box around defender
const bx = def.pos.x - 30
const by2 = def.pos.y - 70
const bw = 60
const bh2 = 80
const top = k.add([k.rect(bw, 2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
const bot = k.add([k.rect(bw, 2), k.pos(bx, by2 + bh2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
const left = k.add([k.rect(2, bh2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
const right = k.add([k.rect(2, bh2), k.pos(bx + bw, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)])
await k.wait(0.3)
// Crush — shrink box
sfxZoomWhoosh()
await Promise.all([
k.tween(bx, def.pos.x - 5, 0.3, (v: number) => { left.pos.x = v; top.pos.x = v; bot.pos.x = v }),
k.tween(bx + bw, def.pos.x + 5, 0.3, (v: number) => { right.pos.x = v }),
])
sfxExplosion()
k.shake(30)
screenFlash('#ffffff', 0.15)
glitchRGB();
[top, bot, left, right].forEach(b => b.destroy())
overlay.destroy()
spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700')
def.play('knockback')
const push = dir * 110
k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad)
await k.wait(0.6)
atk.play('idle')
await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad)
}
const choreographyMap: Record<string, typeof dashPunch> = {
dashPunch, aerialSlam, flyingKick, dashThrough, uppercut,
multiHit, projectile, jetpackDive, gunBurst, groundPound,
@@ -5821,6 +6255,11 @@ export async function createFightScene(config: FightSceneConfig) {
// Tier 5+
ultimateArmageddon, ultimateOmegaBeam, ultimateBeyondTheScreen, ultimateMatrixDodge, ultimateWorldEnder,
ultimateAbsoluteZero, ultimateSolarCannon, ultimatePhantomStrike, ultimateGodPunch, ultimateVoidCollapse,
// --- THE CREATOR: Bitcoin-themed moves ---
bitcoinRain, lightningInvoice, satoshiStrike, hashRateOverload, blockchainSlam, creatorMode,
morphStrike, divineIntervention,
// --- THE CREATOR: Exclusive ultimates ---
ultimateGenesisBlock, ultimateTheHalving, ultimateFullNodeCrush, ultimateSatoshiVision,
}
// === MORPH / COSTUME SYSTEM ===
@@ -6042,6 +6481,86 @@ export async function createFightScene(config: FightSceneConfig) {
const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph]
const morphNames = ['ELEMENTAL FORM', 'MECH ARMOR', 'BEAST MODE']
// === THE CREATOR: OMNI-MORPH ===
// Instead of 3 morph types, the creator morphs into a random archetype each time
const OMNI_MORPH_ARCHETYPES = [
'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'cactus', 'pizza',
'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', 'ghost', 'alien', 'dinosaur',
'pirate', 'ninja', 'cowboy', 'wizard', 'bee', 'frog', 'snail',
'robot', 'android', 'toaster', 'mech', 'minotaur', 'unicorn', 'phoenix', 'dragon',
'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem', 'vampire', 'werewolf', 'zombie',
'witch', 'demon', 'chef', 'firefighter', 'astronaut', 'clown', 'detective',
'lumberjack', 'scientist', 'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight',
'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon', 'snake',
'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda', 'hamster',
'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'cloud_man', 'rock_man',
'balloon_man', 'trash_can', 'rubber_duck', 'snowman', 'scarecrow', 'jack_o_lantern',
'garden_gnome', 'lamp_post', 'broom_man',
]
function applyCreatorOmniMorph(fighter: any, _side: 'a' | 'b'): string {
const pick = OMNI_MORPH_ARCHETYPES[Math.floor(Math.random() * OMNI_MORPH_ARCHETYPES.length)]
// Golden aura unique to creator morph
const aura = k.add([
k.circle(55),
k.pos(fighter.pos.x, fighter.pos.y - 20),
k.color(safeColor(k, '#c8a000')),
k.opacity(0.2),
k.z(fighter.z - 1),
k.anchor('center'),
k.scale(1),
])
aura.onUpdate(() => {
aura.pos.x = fighter.pos.x
aura.pos.y = fighter.pos.y - 20
aura.opacity = 0.12 + Math.sin(k.time() * 6) * 0.1
const s = 1 + Math.sin(k.time() * 4) * 0.2
aura.scale.x = s; aura.scale.y = s
})
activeMorphs.push({ obj: aura, type: 'omni' })
// Orbiting golden ₿ symbols (4 orbiting)
for (let i = 0; i < 4; i++) {
const btc = k.add([
k.text('₿', { size: 10 }),
k.pos(fighter.pos.x, fighter.pos.y - 20),
k.color(safeColor(k, '#ffd700')),
k.opacity(0.7),
k.z(fighter.z + 2),
k.anchor('center'),
])
const baseAngle = (i / 4) * Math.PI * 2
const radius = 40
btc.onUpdate(() => {
const a = baseAngle + k.time() * 3
btc.pos.x = fighter.pos.x + Math.cos(a) * radius
btc.pos.y = fighter.pos.y - 20 + Math.sin(a) * radius * 0.5
btc.opacity = 0.7 + Math.sin(k.time() * 8 + i) * 0.3
})
activeMorphs.push({ obj: btc, type: 'omni' })
}
// Apply archetype-specific color tint based on pick category
const tints: Record<string, string> = {
fire: '#ff4400', ice: '#44ccff', nature: '#44cc44', dark: '#8844cc',
metal: '#aabbcc', electric: '#ffff00', beast: '#cc6622', cosmic: '#cc44ff',
}
const category =
['phoenix', 'dragon', 'demon'].includes(pick) ? 'fire' :
['penguin', 'snowman', 'yeti'].includes(pick) ? 'ice' :
['cactus', 'mushroom', 'frog', 'snail', 'turtle'].includes(pick) ? 'nature' :
['skeleton', 'ghost', 'vampire', 'zombie', 'witch'].includes(pick) ? 'dark' :
['robot', 'android', 'mech', 'toaster', 'knight'].includes(pick) ? 'metal' :
['alien', 'unicorn', 'mermaid'].includes(pick) ? 'cosmic' :
['werewolf', 'minotaur', 'lion', 'shark', 'crocodile'].includes(pick) ? 'beast' :
'electric'
fighter.color = safeColor(k, tints[category] || '#ffd700')
fighter.opacity = 0.9
return pick
}
// Get morph order for a bot (deterministic by seed)
function getMorphOrder(seed: string): number[] {
let h = 0
@@ -6332,6 +6851,49 @@ export async function createFightScene(config: FightSceneConfig) {
return lines
}
// Talking mouth animation — a small rectangle that opens/closes near the fighter's face
const talkingAnims: Record<string, { objs: any[]; timer: ReturnType<typeof setInterval> }> = {}
function startTalking(side: 'a' | 'b') {
stopTalking(side) // cleanup any existing
const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0]
if (!fighter?.exists()) return
const mouthColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
// Mouth position: slightly below center of the sprite
const mouthOffsetY = -28
const mouthW = 8
let open = false
const mouth = k.add([
k.rect(mouthW, 2),
k.pos(fighter.pos.x, fighter.pos.y + mouthOffsetY),
k.color(safeColor(k, mouthColor)),
k.opacity(0.9),
k.z(12),
k.anchor('center'),
])
// Toggle open/close every 120ms for a fast chatter effect
const timer = setInterval(() => {
if (!mouth.exists() || !fighter.exists()) { stopTalking(side); return }
open = !open
mouth.pos.x = fighter.pos.x
mouth.pos.y = fighter.pos.y + mouthOffsetY
mouth.height = open ? 5 : 2
}, 120)
talkingAnims[side] = { objs: [mouth], timer }
}
function stopTalking(side: 'a' | 'b') {
const anim = talkingAnims[side]
if (!anim) return
clearInterval(anim.timer)
anim.objs.forEach(o => { if (o.exists()) o.destroy() })
delete talkingAnims[side]
}
// Spawn floating text above a position
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
const label = k.add([
@@ -6706,6 +7268,9 @@ export async function createFightScene(config: FightSceneConfig) {
showSpeechBubble(side, text, duration)
},
startTalking(side: 'a' | 'b') { startTalking(side) },
stopTalking(side: 'a' | 'b') { stopTalking(side) },
async playEntrance() {
const fA = k.get('fighterA')[0]
const fB = k.get('fighterB')[0]
@@ -7119,16 +7684,77 @@ export async function createFightScene(config: FightSceneConfig) {
},
]
// THE CREATOR: Golden code rain portal entrance
const creatorEntrance = async (fighter: any, homeX: number, _fromLeft: boolean) => {
fighter.pos.x = homeX
fighter.pos.y = -100
fighter.opacity = 0
// Golden code rain columns
const codeChars = '₿01∞⚡⛏§∂∆≈≠'.split('')
const rainDrops: any[] = []
for (let col = 0; col < 12; col++) {
const cx = homeX - 60 + col * 10
for (let row = 0; row < 4; row++) {
const ch = codeChars[Math.floor(Math.random() * codeChars.length)]
const rd = k.add([
k.text(ch, { size: 8 }), k.pos(cx, -20 - row * 25),
k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')),
k.opacity(0.6 + Math.random() * 0.4), k.z(48), k.anchor('center'),
])
rainDrops.push(rd)
k.tween(rd.pos.y, GROUND_Y + 20, 0.6 + Math.random() * 0.3, (v) => {
rd.pos.y = v
rd.opacity = Math.max(0, 1 - (v - GROUND_Y + 30) / 50)
}).then(() => { if (rd.exists()) rd.destroy() })
}
}
await k.wait(0.3)
// Portal flash
screenFlash('#ffd700', 0.15)
spawnShockwave(homeX, GROUND_Y - 30, '#c8a000')
sfxSpecial()
// Fighter descends through golden portal
fighter.opacity = 1
await k.tween(-100, GROUND_Y - 6, 0.5, (v) => {
fighter.pos.y = v
}, k.easings.easeOutBack)
k.shake(12)
sfxExplosion()
// Persistent golden particles around creator
for (let i = 0; i < 6; i++) {
const p = k.add([
k.circle(2), k.pos(homeX, GROUND_Y - 30),
k.color(safeColor(k, '#ffd700')), k.opacity(0.5), k.z(11), k.anchor('center'),
])
p.onUpdate(() => {
const a = k.time() * (2 + i * 0.4) + i
p.pos.x = fighter.pos.x + Math.cos(a) * (20 + i * 5)
p.pos.y = fighter.pos.y - 25 + Math.sin(a) * (15 + i * 3)
p.opacity = 0.3 + Math.sin(k.time() * 6 + i) * 0.3
})
}
announceHype('THE CREATOR HAS ENTERED THE ARENA!')
rainDrops.forEach(r => { if (r.exists()) r.destroy() })
}
// Pick random entrance for each bot (different ones)
const idxA = Math.floor(Math.random() * entrances.length)
let idxB = Math.floor(Math.random() * entrances.length)
while (idxB === idxA && entrances.length > 1) idxB = Math.floor(Math.random() * entrances.length)
// Play entrances with slight stagger
// Play entrances with slight stagger — creator always gets special entrance
announceDeepIntro()
await entrances[idxA](fA, HOME_A, true)
if (botA.archetype === 'the_creator') {
await creatorEntrance(fA, HOME_A, true)
} else {
await entrances[idxA](fA, HOME_A, true)
}
await k.wait(0.3)
await entrances[idxB](fB, HOME_B, false)
if (botB.archetype === 'the_creator') {
await creatorEntrance(fB, HOME_B, false)
} else {
await entrances[idxB](fB, HOME_B, false)
}
await k.wait(0.2)
// Safety: ensure both fighters are visible and at home positions
@@ -7280,22 +7906,56 @@ export async function createFightScene(config: FightSceneConfig) {
}
const attackerTier = attackerSide === 'a' ? botA.tier : botB.tier
const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier)
const attackerArch = attackerSide === 'a' ? botA.archetype : botB.archetype
const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier, attackerArch)
// Morph system: transform during ultimates or devastating crits
const isUltimate = choreo.startsWith('ultimate')
const shouldMorph = isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4)
const isCreatorFighter = attackerArch === 'the_creator'
// Creator: always morph on ultimates, 70% on devastating crits
const shouldMorph = isCreatorFighter
? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7))
: (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4))
let morphRevert: (() => Promise<void>) | null = null
if (shouldMorph) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0]
if (attacker) {
const seed = attackerSide === 'a' ? botA.seed : botB.seed
const morphOrder = getMorphOrder(seed)
// Cycle through morphs based on round number
const morphIdx = morphOrder[event.round % 3]
const result = await playMorph(attacker, attackerSide, morphIdx)
morphRevert = result.revert
await k.wait(0.2)
if (isCreatorFighter) {
// Omni-morph: creator transforms into a random archetype
const morphedTo = applyCreatorOmniMorph(attacker, attackerSide)
screenFlash('#ffd700', 0.15)
sfxZoomWhoosh()
sfxSpecial()
announceHype(`THE CREATOR — ${morphedTo.toUpperCase()} FORM!`)
const savedSX = attacker.scale.x
const savedSY = attacker.scale.y
await k.tween(0, 1, 0.3, (t) => {
attacker.scale.x = savedSX * (1 + t * 0.3)
attacker.scale.y = savedSY * (1 + t * 0.3)
}, k.easings.easeOutBack)
spawnShockwave(attacker.pos.x, attacker.pos.y - 20, '#ffd700')
k.shake(10)
glitchRGB(0.25)
morphRevert = async () => {
destroyMorphOverlays()
screenFlash('#c8a000', 0.1)
attacker.color = safeColor(k, '#ffffff')
attacker.opacity = 1
await k.tween(0, 1, 0.2, (t) => {
attacker.scale.x = savedSX * 1.3 + t * (savedSX - savedSX * 1.3)
attacker.scale.y = savedSY * 1.3 + t * (savedSY - savedSY * 1.3)
}, k.easings.easeInQuad)
}
await k.wait(0.2)
} else {
const seed = attackerSide === 'a' ? botA.seed : botB.seed
const morphOrder = getMorphOrder(seed)
// Cycle through morphs based on round number
const morphIdx = morphOrder[event.round % 3]
const result = await playMorph(attacker, attackerSide, morphIdx)
morphRevert = result.revert
await k.wait(0.2)
}
}
}
// Camera pull-back for ultimates: zoom out slightly to show the full move
@@ -7421,6 +8081,69 @@ export async function createFightScene(config: FightSceneConfig) {
k.wait(0.8).then(() => { if (judge.exists()) judge.play('idle') })
}
// === THE CREATOR CAMEO ===
// 6% chance per round (only if neither fighter IS the creator)
const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator'
if (neitherIsCreator && Math.random() < 0.06) {
const cameoX = k.width() / 2
const cameoY = GROUND_Y - 80
// Golden portal flash
screenFlash('#ffd700', 0.1)
const portal = k.add([
k.circle(30), k.pos(cameoX, cameoY), k.color(safeColor(k, '#c8a000')),
k.opacity(0), k.z(50), k.anchor('center'), k.scale(0.1),
])
await k.tween(0, 1, 0.3, (t) => {
portal.opacity = t * 0.4
portal.scale.x = t * 1.5; portal.scale.y = t * 1.5
}, k.easings.easeOutBack)
// ₿ symbol descends from portal
const gift = k.add([
k.text('₿', { size: 18 }), k.pos(cameoX, cameoY - 30),
k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(51), k.anchor('center'),
])
const creatorLabel = k.add([
k.text('THE CREATOR', { size: 8 }), k.pos(cameoX, cameoY + 25),
k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(51), k.anchor('center'),
])
await k.tween(0, 1, 0.4, (t) => {
gift.opacity = t
gift.pos.y = cameoY - 30 + t * 20
creatorLabel.opacity = t * 0.8
}, k.easings.easeOutQuad)
// Gift flies to the round winner (or random fighter if draw)
const targetFighter = aWon ? fA : bWon ? fB : (Math.random() < 0.5 ? fA : fB)
if (targetFighter) {
const giftTexts = ['POWER UP!', 'BLESSED!', 'SATOSHI\'S GIFT!', 'HODL STRENGTH!', '21M ENERGY!']
const tx = targetFighter.pos.x, ty = targetFighter.pos.y - 30
await k.tween(0, 1, 0.35, (t) => {
gift.pos.x = cameoX + (tx - cameoX) * t
gift.pos.y = (cameoY - 10) + (ty - (cameoY - 10)) * t
}, k.easings.easeInQuad)
// Impact flash on fighter
screenFlash('#ffd700', 0.08)
spawnShockwave(tx, ty, '#ffd700')
const blessText = giftTexts[Math.floor(Math.random() * giftTexts.length)]
const bless = k.add([
k.text(blessText, { size: 10 }), k.pos(tx, ty - 20),
k.color(safeColor(k, '#ffd700')), k.opacity(1), k.z(52), k.anchor('center'),
])
k.tween(0, 1, 0.8, (t) => {
bless.pos.y = ty - 20 - t * 30
bless.opacity = 1 - t
}).then(() => { if (bless.exists()) bless.destroy() })
announceCool('THE CREATOR HAS BLESSED THIS FIGHT!')
}
gift.destroy()
// Fade out portal and label
await k.tween(1, 0, 0.3, (t) => {
portal.opacity = t * 0.4
creatorLabel.opacity = t * 0.8
})
portal.destroy()
creatorLabel.destroy()
}
// Update combos
const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3)
if (aWon) {
+99
View File
@@ -104,6 +104,7 @@ const voiceProfiles: Record<string, VoiceProfile> = {
robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone
screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, // Frantic energy
smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, // Natural narrator
question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, // Clear, brisk question announcer
// 24 new profiles
whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper
boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming
@@ -204,6 +205,12 @@ function loadVoices() {
voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0]
voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)]
voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)]
// Question reader: highest quality voice available — tries premium/neural first
voiceProfiles.question_reader.voice = preferPremium([
/evan.*premium/i, /samantha.*enhanced/i, /daniel.*premium/i,
/google.*us.*english/i, /google.*uk.*english/i,
/evan/i, /aaron/i, /daniel/i, /james/i,
]) || anyVoices[0]
// Assign voices to new profiles — spread across available voices for max variety
const vLen = anyVoices.length
const pick = (i: number) => anyVoices[i % vLen]
@@ -340,6 +347,52 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
speechSynthesis.speak(utter)
}
/** Like speakAsync but with a forced minimum rate */
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
return new Promise<void>((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
if (!voicesLoaded) loadVoices()
if (speechSynthesis.paused) speechSynthesis.resume()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const utter = new SpeechSynthesisUtterance(text)
if (profile.voice) utter.voice = profile.voice
utter.pitch = profile.pitch
utter.rate = Math.max(minRate, profile.rate)
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
const safetyTimeout = setTimeout(resolve, 15_000)
utter.onend = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
utter.onerror = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
speechSynthesis.speak(utter)
})
}
/** Like speak() but returns a promise that resolves when the utterance finishes */
function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise<void> {
return new Promise<void>((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
if (!voicesLoaded) loadVoices()
if (speechSynthesis.paused) speechSynthesis.resume()
if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const utter = new SpeechSynthesisUtterance(text)
if (profile.voice) utter.voice = profile.voice
utter.pitch = profile.pitch
utter.rate = profile.rate
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() }
// Safety timeout — never block forever (max 15s for any utterance)
const safetyTimeout = setTimeout(resolve, 15_000)
const origOnEnd = utter.onend
utter.onend = (ev) => { clearTimeout(safetyTimeout); (origOnEnd as any)(ev) }
const origOnError = utter.onerror
utter.onerror = (ev) => { clearTimeout(safetyTimeout); (origOnError as any)(ev) }
speechSynthesis.speak(utter)
})
}
export function stopAllAudio() {
stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
@@ -512,6 +565,52 @@ export function announceRoundHype() {
voices[Math.floor(Math.random() * voices.length)](line)
}
// === TTS: Questions, Answers & Narration ===
// Distinct voice roles so players can always tell who's speaking.
// Question = clear smooth reader, Answers = unique per-bot (intelligible subset),
// Narration = dramatic judge.
// Only voices with rate >= 0.6 and rate <= 1.3, and pitch between 0.5-1.5
// so every voice is actually understandable by humans
const INTELLIGIBLE_VOICES = [
'smooth', 'robot', 'drill', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v',
'posh', 'aussie', 'scottish', 'french', 'texan', 'wrestler_v', 'ai_core',
'android_v', 'siri', 'professor', 'npc', 'news', 'crotchety', 'mech',
'wizard_v', 'echo_v', 'boss_taunt', 'angel', 'punk', 'valley',
]
function hashName(name: string): number {
let h = 0
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
return Math.abs(h)
}
function botVoiceKey(name: string): string {
return INTELLIGIBLE_VOICES[hashName(name) % INTELLIGIBLE_VOICES.length]
}
/** Speak the challenge question — premium voice, clear and brisk.
* Does NOT cancel previous speech so intro/hype lines finish naturally.
* Returns promise that resolves when the question finishes reading. */
export function speakQuestion(text: string): Promise<void> {
const trimmed = text.length > 140 ? text.slice(0, 137) + '...' : text
return speakAsync(trimmed, 'question_reader', false)
}
/** Speak a bot's answer in their assigned character voice at brisk pace.
* Returns promise that resolves when finished. */
export function speakAnswer(botName: string, answer: string): Promise<void> {
const trimmed = answer.length > 80 ? answer.slice(0, 77) + '...' : answer
// Use speakAsync with a rate boost: override profile rate to min 1.1
return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15)
}
/** Speak the judge's narration — brisk sportscaster energy.
* Returns promise that resolves when finished. */
export function speakNarration(text: string): Promise<void> {
const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text
return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
}
// === FANFARES (8-bit melodic announcements) ===
export function fanfareRound(roundNum: number) {
@@ -34,6 +34,8 @@ import { chef, firefighter, astronautArch, clown, detective, nurse, lumberjack,
import { elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster } from './batch_animals2'
// Batch 8: Silly & Objects
import { sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan } from './batch_silly'
// The Creator (game founder — unique god-tier character)
import { theCreator } from './the_creator'
export const archetypes: Archetype[] = [
// Original 6
@@ -54,6 +56,8 @@ export const archetypes: Archetype[] = [
elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster,
// Batch 8: silly & objects
sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan,
// The Creator (god-tier, weight: 0 — never randomly rolled)
theCreator,
]
export function rollArchetype(roll: number): Archetype {
@@ -0,0 +1,313 @@
import type { Archetype } from '../constants'
export const theCreator: Archetype = {
name: 'the_creator',
weight: 0, // Never randomly rolled — only assigned via pubkey detection
canHaveVisor: false,
canHaveMohawk: false,
canHaveHorns: false,
drawFeatures: (p) => {
const { px, fill, pal, ox, oy, t, frame, tier, bounce,
idle, atk, kick, special, hit, knockback, ko, win,
cx, hOff, vBounce, globalY, koSlump,
bx, by, bw, bh, hx, hy, hw, hh,
armLx, armAttach, armW, armH, feetY } = p
// ═══════════════════════════════════════════
// THE CREATOR — God-tier unique character
// Guy Fawkes mask, hooded cloak, laptop,
// bitcoin chest emblem, orbiting gold particles,
// matrix code rain, tier-gated divine effects
// ═══════════════════════════════════════════
// Color constants
const MASK = '#f5f0e0' // warm ivory
const MASK_SHD = '#ddd5c0' // mask shadow
const HOOD = '#1a1a1a' // dark hood
const HOOD_LT = '#2a2a2a' // hood highlight
const GOLD = '#ffd700' // bitcoin gold
const GOLD_DK = '#c8a000' // dark gold
const GOLD_LT = '#ffee88' // light gold
const MATRIX = '#00ff41' // matrix green
const MATRIX_DK = '#00cc33' // darker green
const LAPTOP = '#333333' // laptop body
const SCREEN = '#00ff41' // laptop screen
// ── HOOD / CLOAK ──
// Dark hood drapes over head and extends down behind body
if (!ko) {
// Hood over head (extends above and around the head)
for (let iy = hy - 2; iy < hy + Math.floor(hh * 0.5); iy++) {
for (let ix = hx - 1; ix <= hx + hw; ix++) {
px(ix, iy, HOOD, ox, oy)
}
}
// Hood peak
px(cx + hOff, hy - 3, HOOD, ox, oy)
px(cx + hOff - 1, hy - 3, HOOD, ox, oy)
px(cx + hOff + 1, hy - 3, HOOD, ox, oy)
px(cx + hOff, hy - 4, HOOD, ox, oy)
// Hood highlight
px(hx, hy - 1, HOOD_LT, ox, oy)
px(hx + 1, hy - 2, HOOD_LT, ox, oy)
// Cloak behind body (darker layer extending from shoulders)
const cloakTop = by - 1
const cloakBot = feetY + globalY + 2
for (let iy = cloakTop; iy < cloakBot; iy++) {
const spread = Math.floor((iy - cloakTop) / 3)
px(bx - 2 - spread, iy, HOOD, ox, oy)
px(bx + bw + 1 + spread, iy, HOOD, ox, oy)
// Cloak back fill
if (iy > by + bh) {
for (let ix = bx - 1 - spread; ix <= bx + bw + spread; ix++) {
px(ix, iy, HOOD, ox, oy)
}
}
}
// Matrix code rain through cloak (animated green pixels)
const codeStreams = 3 + Math.floor(tier * 0.5)
for (let s = 0; s < codeStreams; s++) {
const streamX = bx - 1 + Math.floor((bw + 2) * s / codeStreams)
const phase = (t + s * 0.3) % 1
const streamLen = 3 + (s % 2)
for (let d = 0; d < streamLen; d++) {
const sy = cloakTop + Math.floor(phase * (cloakBot - cloakTop)) + d
if (sy < cloakBot && sy > by + bh - 2) {
const alpha = d === 0 ? MATRIX : d === 1 ? MATRIX_DK : '#006622'
px(streamX, sy, alpha, ox, oy)
}
}
}
}
// ── GUY FAWKES MASK ──
// Distinctive features: pointed chin, arched brows, upward mustache, eternal smile
if (!ko) {
const faceTop = hy + Math.floor(hh * 0.25)
const faceBot = hy + Math.floor(hh * 0.8)
const faceCx = cx + hOff
// Mask base — cream/ivory covering the face area
for (let iy = faceTop; iy < faceBot; iy++) {
const inset = iy === faceTop || iy === faceBot - 1 ? 1 : 0
for (let ix = hx + 2 + inset; ix < hx + hw - 2 - inset; ix++) {
px(ix, iy, MASK, ox, oy)
}
// Right-side shadow
px(hx + hw - 3, iy, MASK_SHD, ox, oy)
}
// Pointed chin extending below face
const chinY = faceBot
px(faceCx, chinY, MASK, ox, oy)
px(faceCx - 1, chinY, MASK_SHD, ox, oy)
px(faceCx + 1, chinY, MASK_SHD, ox, oy)
px(faceCx, chinY + 1, MASK_SHD, ox, oy)
// Eye slits — narrow golden glow
const eyeY = faceTop + Math.max(1, Math.floor((faceBot - faceTop) * 0.25))
const leX = faceCx - Math.floor(hw * 0.2)
const reX = faceCx + Math.floor(hw * 0.15)
// Arched eyebrows above eyes
px(leX - 1, eyeY - 1, '#333333', ox, oy)
px(leX, eyeY - 1, '#333333', ox, oy)
px(leX + 1, eyeY - 1, '#333333', ox, oy)
px(reX - 1, eyeY - 1, '#333333', ox, oy)
px(reX, eyeY - 1, '#333333', ox, oy)
px(reX + 1, eyeY - 1, '#333333', ox, oy)
// Eye slits with golden glow (pulsing)
const eyeColor = frame % 3 === 0 ? GOLD_LT : GOLD
px(leX, eyeY, eyeColor, ox, oy)
px(leX + 1, eyeY, eyeColor, ox, oy)
px(reX, eyeY, eyeColor, ox, oy)
px(reX + 1, eyeY, eyeColor, ox, oy)
// Eye glow halo (subtle)
if (special || atk) {
px(leX - 1, eyeY, GOLD_DK, ox, oy)
px(reX + 2, eyeY, GOLD_DK, ox, oy)
}
// Rosy cheeks
const cheekY = eyeY + 2
px(leX - 1, cheekY, '#cc8888', ox, oy)
px(reX + 2, cheekY, '#cc8888', ox, oy)
// Upward-curling mustache
const stacheY = cheekY + 1
px(faceCx - 1, stacheY, '#222222', ox, oy)
px(faceCx, stacheY, '#222222', ox, oy)
px(faceCx + 1, stacheY, '#222222', ox, oy)
// Curled tips upward
px(faceCx - 2, stacheY - 1, '#222222', ox, oy)
px(faceCx + 2, stacheY - 1, '#222222', ox, oy)
// Eternal smile
const smileY = stacheY + 1
px(faceCx - 2, smileY, '#333333', ox, oy)
px(faceCx - 1, smileY + 1, '#333333', ox, oy)
px(faceCx, smileY + 1, '#333333', ox, oy)
px(faceCx + 1, smileY + 1, '#333333', ox, oy)
px(faceCx + 2, smileY, '#333333', ox, oy)
// Thin goatee below chin
px(faceCx, chinY + 2, '#222222', ox, oy)
}
// ── BITCOIN SYMBOL ON CHEST ──
// Always visible — pulsing gold ₿
const btcColor = frame % 4 < 2 ? GOLD : GOLD_LT
const btcX = cx + hOff - 1
const btcY = by + Math.max(1, Math.floor(bh * 0.25))
// Vertical stroke of ₿
px(btcX, btcY, btcColor, ox, oy)
px(btcX, btcY + 1, btcColor, ox, oy)
px(btcX, btcY + 2, btcColor, ox, oy)
px(btcX, btcY + 3, btcColor, ox, oy)
// Right bumps of ₿
px(btcX + 1, btcY, btcColor, ox, oy)
px(btcX + 2, btcY + 1, btcColor, ox, oy)
px(btcX + 1, btcY + 2, btcColor, ox, oy)
px(btcX + 2, btcY + 3, btcColor, ox, oy)
px(btcX + 1, btcY + 4, btcColor, ox, oy)
// Top/bottom serifs
px(btcX, btcY - 1, GOLD_DK, ox, oy)
px(btcX, btcY + 4, GOLD_DK, ox, oy)
// ── LAPTOP IN LEFT HAND ──
// Replaces left arm visual — laptop body + green terminal screen
if (!ko && !knockback) {
const lapX = armLx - 2 + hOff
const lapY = armAttach + Math.floor(armH * 0.3) + globalY + vBounce
// Laptop body
fill(lapX, lapY, 6, 4, LAPTOP, ox, oy)
px(lapX, lapY, '#444444', ox, oy) // corner highlight
// Screen
const screenColor = (atk || special) ? '#ffffff' : SCREEN
fill(lapX + 1, lapY + 1, 4, 2, screenColor, ox, oy)
// Blinking cursor
if (frame % 2 === 0 && !atk && !special) {
px(lapX + 3, lapY + 2, '#000000', ox, oy)
}
// Magic emanating from laptop during attacks
if ((atk || special) && t > 0.2) {
const sparkDist = Math.floor(t * 6)
px(lapX + 3, lapY - sparkDist, GOLD, ox, oy)
px(lapX + 1, lapY - sparkDist - 1, MATRIX, ox, oy)
px(lapX + 5, lapY - sparkDist + 1, GOLD_LT, ox, oy)
if (sparkDist > 2) {
px(lapX + 2, lapY - sparkDist - 2, '#ffffff', ox, oy)
}
}
}
// ── FLOATING BITCOIN PARTICLES ──
// 4-6 gold dots orbiting the character always
if (!ko) {
const numParticles = 4 + Math.floor(tier * 0.5)
const orbitCx = cx + hOff
const orbitCy = by + Math.floor(bh / 2) + vBounce + globalY
for (let i = 0; i < numParticles; i++) {
const angle = t * Math.PI * 2 + i * (Math.PI * 2 / numParticles)
const radius = 8 + i * 2
const px2 = orbitCx + Math.round(Math.cos(angle) * radius)
const py2 = orbitCy + Math.round(Math.sin(angle) * radius * 0.6)
const colors = [GOLD, GOLD_LT, '#ff8c00', GOLD_DK, '#ffffff']
if ((frame + i) % 2 === 0) {
px(px2, py2, colors[i % colors.length], ox, oy)
}
}
}
// ── TIER 3+: LIGHTNING CROWN ──
if (tier >= 3 && !ko) {
const crownY = hy - 5 + vBounce + globalY
// 3 golden lightning points
for (let i = -1; i <= 1; i++) {
const crownX = cx + hOff + i * 3
const flash = (frame + i * 2) % 3 === 0 ? '#ffffff' : GOLD
px(crownX, crownY, flash, ox, oy)
px(crownX, crownY + 1, GOLD_DK, ox, oy)
}
// Lightning bolts connecting crown points
if (frame % 2 === 0) {
px(cx + hOff - 1, crownY, GOLD_LT, ox, oy)
px(cx + hOff + 1, crownY, GOLD_LT, ox, oy)
}
}
// ── TIER 4+: CODE WINGS ──
if (tier >= 4 && !ko && !knockback) {
const wingY = by + 1 + vBounce + globalY
const wingSpan = 4 + tier
// Left wing
for (let w = 0; w < wingSpan; w++) {
const wy = wingY + Math.floor(w * 0.4)
px(bx - 3 - w, wy, MATRIX, ox, oy)
if (w % 2 === 0) px(bx - 3 - w, wy - 1, MATRIX_DK, ox, oy)
}
// Right wing
for (let w = 0; w < wingSpan; w++) {
const wy = wingY + Math.floor(w * 0.4)
px(bx + bw + 2 + w, wy, MATRIX, ox, oy)
if (w % 2 === 0) px(bx + bw + 2 + w, wy - 1, MATRIX_DK, ox, oy)
}
// Wing glow particles
if (idle || win) {
const wgPhase = t * Math.PI * 2
px(bx - 4 - wingSpan, wingY + Math.round(Math.sin(wgPhase) * 2), GOLD, ox, oy)
px(bx + bw + 3 + wingSpan, wingY + Math.round(Math.cos(wgPhase) * 2), GOLD, ox, oy)
}
}
// ── TIER 5+: SATOSHI HALO ──
if (tier >= 5 && !ko) {
const haloY = hy - 7 + vBounce + globalY
const haloCx = cx + hOff
const haloR = 5
// 6 orbiting satoshi dots forming a halo ring
for (let i = 0; i < 6; i++) {
const angle = t * Math.PI * 1.5 + i * (Math.PI * 2 / 6)
const sx = haloCx + Math.round(Math.cos(angle) * haloR)
const sy = haloY + Math.round(Math.sin(angle) * 2)
const sColor = i % 2 === 0 ? GOLD : '#ffffff'
px(sx, sy, sColor, ox, oy)
}
}
// ── WIN POSE: GOLDEN EXPLOSION ──
if (win) {
const burstCx = cx + hOff
const burstCy = by + Math.floor(bh / 2) + vBounce + globalY
const burstR = 12 + tier * 2
for (let i = 0; i < 8; i++) {
const angle = t * Math.PI * 4 + i * (Math.PI / 4)
const dist = burstR * (0.5 + t * 0.5)
const bpx = burstCx + Math.round(Math.cos(angle) * dist)
const bpy = burstCy + Math.round(Math.sin(angle) * dist * 0.6)
const bColor = i % 3 === 0 ? GOLD : i % 3 === 1 ? GOLD_LT : '#ffffff'
if ((frame + i) % 2 === 0) px(bpx, bpy, bColor, ox, oy)
}
}
// ── KO: MASK CRACKS + FADING ──
if (ko) {
// Cracked mask fragments scattered
const faceCx = cx + hOff
const fY = hy + Math.floor(hh * 0.4)
px(faceCx - 1, fY + koSlump, MASK_SHD, ox, oy)
px(faceCx + 1, fY + 1 + koSlump, MASK, ox, oy)
px(faceCx, fY + 2 + koSlump, MASK_SHD, ox, oy)
// Fading golden eye
if (frame % 3 === 0) {
px(faceCx - 1, fY - 1 + koSlump, GOLD_DK, ox, oy)
}
// Laptop fallen on ground
fill(faceCx + 3, feetY + globalY - 1, 4, 2, LAPTOP, ox, oy)
px(faceCx + 4, feetY + globalY - 1, SCREEN, ox, oy)
}
},
}
+13
View File
@@ -34,6 +34,19 @@ export function generateSpriteSheet(
const finalSecondary = customization?.secondaryColor || secondaryColor
const pal = makePal(finalPrimary, finalSecondary, tier)
// The Creator — forced golden palette + golden outline (totally unique)
if ((customization?.archetype || archetypeOverride) === 'the_creator') {
pal.body = 'hsl(43, 85%, 52%)'
pal.dark = 'hsl(43, 85%, 32%)'
pal.light = 'hsl(43, 95%, 67%)'
pal.acc = 'hsl(30, 90%, 55%)'
pal.accDark = 'hsl(30, 90%, 35%)'
pal.accLight = 'hsl(30, 95%, 70%)'
pal.out = '#c8a000' // golden outline instead of black
pal.skin = '#f5f0e0' // mask ivory
pal.skinDark = '#ddd5c0'
}
let sh = 0
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
const rng = () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 }
+11 -2
View File
@@ -72,8 +72,17 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
return c.body(readFileSync(resolved))
}
// Hashed assets — immutable cache
app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
// Hashed assets — immutable cache. If missing (stale deploy), return JS that triggers reload.
app.get('/assets/*', (c) => {
const resolved = join(publicDir, c.req.path)
if (!resolved.startsWith(publicDir) || !existsSync(resolved)) {
// Stale chunk hash from old deploy — tell the browser to reload
c.header('Content-Type', 'application/javascript')
c.header('Cache-Control', 'no-cache')
return c.body('window.location.reload();')
}
return serveFile(c, c.req.path, 'public, max-age=31536000, immutable')
})
// Root static files (favicon, manifest, robots, etc.)
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
+1 -1
View File
@@ -17,7 +17,7 @@ const VALID_ARCHETYPES = new Set([
'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato',
'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck',
'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post',
'broom_man', 'human',
'broom_man', 'human', 'the_creator',
])
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
+18 -3
View File
@@ -52,17 +52,32 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
// SSRF protection: block internal/private URLs
function isAllowedWebhookUrl(url: string): boolean {
try {
if (typeof url !== 'string' || url.length > 2048) return false
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
const hostname = parsed.hostname.toLowerCase()
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
// Localhost variants
if (hostname === 'localhost' || hostname === '::1') return false
if (hostname.startsWith('127.')) return false
// IPv6-mapped IPv4 localhost
if (hostname.startsWith('::ffff:127.')) return false
// Private IPv4 ranges
if (hostname.startsWith('10.')) return false
if (hostname.startsWith('192.168.')) return false
if (hostname.startsWith('172.')) {
const second = parseInt(hostname.split('.')[1])
if (second >= 16 && second <= 31) return false
}
if (hostname === '169.254.169.254') return false
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
// Link-local and metadata
if (hostname.startsWith('169.254.')) return false
// IPv6 private (fc00::/7)
if (hostname.startsWith('fc') || hostname.startsWith('fd')) return false
// IPv6 link-local (fe80::/10)
if (hostname.startsWith('fe80')) return false
// Reserved TLDs
if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) return false
// Null byte injection
if (hostname.includes('\0')) return false
return true
} catch {
return false
+78 -5
View File
@@ -246,6 +246,26 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
// Dev mode: use Lightning Address from env to avoid self-payment on same LND node
const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : ''
// Look up winner name for payout description
const winnerRows = await db.select({ name: schema.bots.name })
.from(schema.bots).where(eq(schema.bots.id, winnerId)).limit(1)
const winnerName = winnerRows[0]?.name || 'unknown'
// Look up loser for the payout message
const fightRows = await db.select({
botAId: schema.fights.botAId,
botBId: schema.fights.botBId,
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
let loserName = 'opponent'
if (fightRows.length > 0) {
const loserId = fightRows[0].botAId === winnerId ? fightRows[0].botBId : fightRows[0].botAId
const loserRows = await db.select({ name: schema.bots.name })
.from(schema.bots).where(eq(schema.bots.id, loserId)).limit(1)
loserName = loserRows[0]?.name || 'opponent'
}
const payoutDesc = `BOTFIGHTS VICTORY: ${winnerName} defeated ${loserName}! ${POT_SATS} sats prize`
// Look up winner's wallet connection
const walletRows = await db.select().from(schema.walletConnections)
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
@@ -262,13 +282,13 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
if (devPayoutAddr) {
// Dev mode: pay to configured Lightning Address (different node, avoids self-payment)
console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`)
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS)
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS, payoutDesc)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'lnaddress') {
// Resolve Lightning Address → LNURL → invoice → pay
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS)
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS, payoutDesc)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
@@ -276,7 +296,7 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
// Request invoice from winner's NWC wallet, then pay it via server wallet
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
amount: POT_SATS * 1000,
description: `Botfights ranked win payout (${POT_SATS} sats)`,
description: payoutDesc,
})
invoice = winnerResult.invoice as string
if (!invoice) throw new Error('Winner NWC make_invoice returned no invoice')
@@ -419,7 +439,7 @@ async function nwcRequestVia(
}
/** Resolve a Lightning Address to a BOLT11 invoice */
async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): Promise<string> {
async function resolveAndCreateInvoice(lnAddress: string, amountSats: number, comment?: string): Promise<string> {
const [name, domain] = lnAddress.split('@')
if (!name || !domain) throw new Error(`Invalid Lightning Address: ${lnAddress}`)
@@ -443,6 +463,7 @@ async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): P
const callbackUrl = new URL(data.callback)
callbackUrl.searchParams.set('amount', String(amountMillisats))
if (comment) callbackUrl.searchParams.set('comment', comment)
const invoiceRes = await fetch(callbackUrl.toString())
if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`)
@@ -474,7 +495,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
if (walletRows[0]?.method === 'nwc') {
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
amount: ENTRY_FEE_SATS * 1000,
description: 'Botfights ranked refund',
description: `BOTFIGHTS REFUND: ${ENTRY_FEE_SATS} sats ranked entry fee returned`,
})
const invoice = result.invoice as string
if (invoice) {
@@ -609,4 +630,56 @@ export async function recoverOrphanedPayments(): Promise<void> {
}
}
// In-memory set of payment IDs currently consumed by the ranked queue.
// Safe because Node.js is single-threaded and the ranked queue is also in-memory.
// On server restart, the queue is empty and recoverOrphanedPayments handles cleanup.
const consumedPayments = new Set<string>()
/**
* Consume a confirmed entry payment for queue use.
* Returns true if the payment was successfully consumed, false if already used.
*/
export async function consumePaymentForQueue(paymentId: string, botId: string): Promise<boolean> {
// Fast path: already consumed in this server lifetime
if (consumedPayments.has(paymentId)) return false
// Verify payment belongs to this bot, is confirmed, inbound, and not linked to a fight
const rows = await db.select({
id: schema.payments.id,
botId: schema.payments.botId,
status: schema.payments.status,
direction: schema.payments.direction,
fightId: schema.payments.fightId,
}).from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1)
if (rows.length === 0) return false
const p = rows[0]
if (p.botId !== botId) return false
if (p.status !== 'confirmed') return false
if (p.direction !== 'in') return false
if (p.fightId !== null) return false // already linked to a fight
consumedPayments.add(paymentId)
return true
}
/**
* Link consumed entry payments to the actual fight.
* Called after a ranked fight is created. Also removes from consumed set.
*/
export async function linkPaymentsToFight(fightId: string, paymentIds: string[]): Promise<void> {
for (const pid of paymentIds) {
await db.update(schema.payments).set({ fightId })
.where(eq(schema.payments.id, pid))
consumedPayments.delete(pid)
}
}
/**
* Release a consumed payment back for refund when queue times out or bot leaves.
*/
export function releasePayment(paymentId: string): void {
consumedPayments.delete(paymentId)
}
export { ENTRY_FEE_SATS, POT_SATS }
+26 -6
View File
@@ -1,7 +1,7 @@
import { db, schema } from '../db/index.js'
import { eq, sql } from 'drizzle-orm'
import { runFightAsync, isInFight } from './orchestrator.js'
import { checkPaymentStatus, refundEntry } from './payments.js'
import { checkPaymentStatus, refundEntry, consumePaymentForQueue, linkPaymentsToFight, releasePayment } from './payments.js'
interface RankedQueueEntry {
botId: string
@@ -36,35 +36,49 @@ export function getRankedQueueStatus(): { waiting: number } {
* NEVER matches against mock bots.
*/
export async function joinRankedQueue(botId: string, paymentId: string): Promise<string> {
// Verify payment is confirmed
// Verify payment is confirmed (checks NWC if still pending)
const status = await checkPaymentStatus(paymentId)
if (status !== 'confirmed') {
throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`)
}
// Consume the payment — prevents double-spend
// Verifies payment is confirmed, unused, belongs to this bot
const consumed = await consumePaymentForQueue(paymentId, botId)
if (!consumed) {
throw new Error('Payment already used or does not belong to this bot.')
}
// Check cooldown
const cooldownUntil = rankedCooldowns.get(botId)
if (cooldownUntil && Date.now() < cooldownUntil) {
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
releasePayment(paymentId)
throw new Error(`Cooldown active. Wait ${waitSec}s.`)
}
// Check if already in a fight
if (isInFight(botId)) {
releasePayment(paymentId)
throw new Error('Bot is already in a fight.')
}
// Load bot
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0) throw new Error('Bot not found')
if (botRows.length === 0) {
releasePayment(paymentId)
throw new Error('Bot not found')
}
const bot = botRows[0]
if (!bot.isActive) {
releasePayment(paymentId)
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
}
// NEVER allow mock/classic bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
releasePayment(paymentId)
throw new Error('Practice bots cannot join ranked fights.')
}
@@ -75,6 +89,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
if (existing !== -1) {
const old = rankedQueue.splice(existing, 1)[0]
clearTimeout(old.timeoutHandle)
// Release the OLD payment (the new one is already consumed)
releasePayment(old.paymentId)
old.reject(new Error('Rejoined ranked queue'))
}
@@ -89,8 +105,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const opponent = rankedQueue.shift()!
clearTimeout(opponent.timeoutHandle)
// Start ranked fight
// Start ranked fight and link both payments
const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId])
opponent.resolve(fightId)
return fightId
}
@@ -103,6 +120,7 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const mock = mockBots[Math.floor(Math.random() * mockBots.length)]
console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`)
const fightId = await runFightAsync(botId, mock.id, 'ranked')
await linkPaymentsToFight(fightId, [paymentId])
return fightId
}
}
@@ -113,7 +131,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const idx = rankedQueue.findIndex(e => e.botId === botId)
if (idx !== -1) {
rankedQueue.splice(idx, 1)
// Refund the entry fee
// Release payment back to refundable state, then refund
releasePayment(paymentId)
try {
await refundEntry(paymentId)
reject(new Error('No ranked opponent found — entry fee refunded.'))
@@ -146,8 +165,9 @@ export async function leaveRankedQueue(botId: string): Promise<boolean> {
const entry = rankedQueue.splice(idx, 1)[0]
clearTimeout(entry.timeoutHandle)
// Refund entry fee
// Release payment, then refund
try {
releasePayment(entry.paymentId)
await refundEntry(entry.paymentId)
} catch (err) {
console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err)
+21 -3
View File
@@ -10,6 +10,9 @@ import { rateLimit } from '../middleware/rate-limit.js'
export const authRouter = new Hono()
// The Creator — game founder pubkey (auto-assigns the_creator archetype)
const CREATOR_PUBKEY = "da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39"
// Check name availability
authRouter.get("/check-name/:name", async (c) => {
const name = c.req.param("name")?.trim().toLowerCase()
@@ -58,6 +61,12 @@ authRouter.post('/login', async (c) => {
const bot = rows[0]
const isHuman = bot.webhookUrl === 'http://human.local/'
// Auto-upgrade: if creator logs in, ensure archetype is always the_creator
if (pubkey === CREATOR_PUBKEY && bot.archetype !== "the_creator") {
await db.update(schema.bots).set({ archetype: "the_creator" }).where(eq(schema.bots.id, bot.id))
bot.archetype = "the_creator"
}
return c.json({
exists: true,
bot: {
@@ -154,7 +163,8 @@ authRouter.post('/register', rateLimit(3600_000, 15), async (c) => {
const id = nanoid(12)
const secret = randomBytes(32).toString('hex')
const effectiveArchetype = custResult.data.archetype || archetype || 'standard'
const baseArchetype = custResult.data.archetype || archetype || 'standard'
const effectiveArchetype = pubkey === CREATOR_PUBKEY ? 'the_creator' : baseArchetype
const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null
await db.insert(schema.bots).values({
@@ -248,7 +258,7 @@ authRouter.post('/update', async (c) => {
const body = await c.req.json()
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
if (!pubkey || typeof pubkey !== 'string') {
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
@@ -264,6 +274,9 @@ authRouter.post('/update', async (c) => {
const updates: Record<string, unknown> = {}
if (webhookUrl) {
if (typeof webhookUrl !== 'string' || webhookUrl.length > 2048) {
return c.json({ error: 'Invalid webhookUrl.' }, 400)
}
if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
}
@@ -279,7 +292,12 @@ authRouter.post('/update', async (c) => {
updates.isActive = true
}
if (profilePicUrl) updates.profilePicUrl = profilePicUrl
if (profilePicUrl) {
if (typeof profilePicUrl !== 'string' || profilePicUrl.length > 2048 || !/^https?:\/\//.test(profilePicUrl)) {
return c.json({ error: 'profilePicUrl must be a valid HTTP(S) URL.' }, 400)
}
updates.profilePicUrl = profilePicUrl
}
if (rawCustomization !== undefined) {
const custResult = validateCustomization(rawCustomization)
+49 -8
View File
@@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { encrypt, decrypt } from '../engine/crypto.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const paymentsRouter = new Hono()
@@ -82,11 +83,23 @@ paymentsRouter.get('/wallet-status', async (c) => {
return c.json({ connected: true, method: walletRows[0].method })
})
// POST /create-invoice
paymentsRouter.post('/create-invoice', async (c) => {
const { botId } = await c.req.json<{ botId: string }>()
// POST /create-invoice — rate limited: 10 per minute per IP
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>()
if (!botId) return c.json({ error: 'Missing botId' }, 400)
// In production, verify bot ownership
if (process.env.NODE_ENV === 'production') {
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Missing pubkey' }, 400)
}
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
}
try {
const result = await createEntryInvoice(botId)
return c.json(result)
@@ -96,9 +109,12 @@ paymentsRouter.post('/create-invoice', async (c) => {
}
})
// GET /check/:paymentId
paymentsRouter.get('/check/:paymentId', async (c) => {
// GET /check/:paymentId — rate limited: 30 per minute per IP
paymentsRouter.get('/check/:paymentId', rateLimit(60_000, 30), async (c) => {
const paymentId = c.req.param('paymentId')
if (!paymentId || paymentId.length > 24) {
return c.json({ error: 'Invalid paymentId' }, 400)
}
try {
const status = await checkPaymentStatus(paymentId)
return c.json({ status })
@@ -109,8 +125,12 @@ paymentsRouter.get('/check/:paymentId', async (c) => {
})
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage
paymentsRouter.post('/confirm/:paymentId', async (c) => {
paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
const paymentId = c.req.param('paymentId')
if (!paymentId || paymentId.length > 24) {
return c.json({ error: 'Invalid paymentId' }, 400)
}
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
const rows = await db.select().from(schema.payments)
@@ -119,16 +139,37 @@ paymentsRouter.post('/confirm/:paymentId', async (c) => {
const payment = rows[0]
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' })
if (payment.status !== 'pending') return c.json({ error: 'Payment is not pending' }, 400)
// Must be an inbound entry payment
if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
// Verify caller owns this payment's bot
if (pubkey) {
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
} else if (process.env.NODE_ENV === 'production') {
return c.json({ error: 'Missing pubkey' }, 400)
return c.json({ error: 'Missing or invalid pubkey' }, 400)
}
// In production, also verify payment via NWC lookup (belt and suspenders)
if (process.env.NODE_ENV === 'production' && payment.invoice && payment.invoice !== 'dev_auto_confirmed') {
try {
const serverStatus = await checkPaymentStatus(paymentId)
if (serverStatus !== 'confirmed') {
return c.json({ error: 'Server could not verify payment. Try again.' }, 402)
}
// checkPaymentStatus already updated the DB
return c.json({ status: 'confirmed' })
} catch {
// NWC check failed — fall through to client-confirmed path with preimage
if (!preimage) {
return c.json({ error: 'Payment verification failed and no preimage provided.' }, 402)
}
}
}
await db.update(schema.payments).set({
+15 -2
View File
@@ -3,6 +3,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js'
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const queueRouter = new Hono()
@@ -48,15 +49,27 @@ queueRouter.get('/ranked-status', (c) => {
return c.json(getRankedQueueStatus())
})
// Join ranked queue — requires confirmed payment
// Join ranked queue — requires confirmed payment + bot ownership
queueRouter.post('/join-ranked/:botId', async (c) => {
const botId = c.req.param('botId')
const { paymentId } = await c.req.json<{ paymentId: string }>()
const { paymentId, pubkey } = await c.req.json<{ paymentId: string; pubkey?: string }>()
if (!paymentId) {
return c.json({ error: 'Missing paymentId' }, 400)
}
// Verify bot ownership in production
if (process.env.NODE_ENV === 'production') {
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Missing pubkey' }, 400)
}
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
}
try {
const fightId = await joinRankedQueue(botId, paymentId)
return c.json({ fightId, message: 'Ranked match found! Fight starting.' })