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:
co-authored by
Claude Opus 4.6
parent
8a3480e5ab
commit
6d390f69b2
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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}$/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.' })
|
||||
|
||||
Reference in New Issue
Block a user