534 lines
14 KiB
Svelte
534 lines
14 KiB
Svelte
<script lang="ts">
|
||
// https://github.com/gre/bezier-easing
|
||
// BezierEasing - use bezier curve for transition easing function
|
||
// by Gaëtan Renaudeau 2014 - 2015 – MIT License
|
||
|
||
// These values are established by empiricism with tests (tradeoff: performance VS precision)
|
||
const NEWTON_ITERATIONS = 4;
|
||
const NEWTON_MIN_SLOPE = 0.001;
|
||
const SUBDIVISION_PRECISION = 0.0000001;
|
||
const SUBDIVISION_MAX_ITERATIONS = 10;
|
||
|
||
const kSplineTableSize = 11;
|
||
const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0);
|
||
|
||
const float32ArraySupported = typeof Float32Array === 'function';
|
||
|
||
function A(aA1: number, aA2: number) {
|
||
return 1.0 - 3.0 * aA2 + 3.0 * aA1;
|
||
}
|
||
function B(aA1: number, aA2: number) {
|
||
return 3.0 * aA2 - 6.0 * aA1;
|
||
}
|
||
function C(aA1: number) {
|
||
return 3.0 * aA1;
|
||
}
|
||
|
||
// Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2.
|
||
function calcBezier(aT: number, aA1: number, aA2: number) {
|
||
return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT;
|
||
}
|
||
|
||
// Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.
|
||
function getSlope(aT: number, aA1: number, aA2: number) {
|
||
return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
|
||
}
|
||
|
||
function binarySubdivide(aX: number, aA: number, aB: number, mX1: number, mX2: number) {
|
||
let currentX,
|
||
currentT,
|
||
i = 0;
|
||
do {
|
||
currentT = aA + (aB - aA) / 2.0;
|
||
currentX = calcBezier(currentT, mX1, mX2) - aX;
|
||
if (currentX > 0.0) {
|
||
aB = currentT;
|
||
} else {
|
||
aA = currentT;
|
||
}
|
||
} while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS);
|
||
return currentT;
|
||
}
|
||
|
||
function newtonRaphsonIterate(aX: number, aGuessT: number, mX1: number, mX2: number) {
|
||
for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
|
||
const currentSlope = getSlope(aGuessT, mX1, mX2);
|
||
if (currentSlope === 0.0) {
|
||
return aGuessT;
|
||
}
|
||
const currentX = calcBezier(aGuessT, mX1, mX2) - aX;
|
||
aGuessT -= currentX / currentSlope;
|
||
}
|
||
return aGuessT;
|
||
}
|
||
|
||
function LinearEasing(x: number) {
|
||
return x;
|
||
}
|
||
|
||
function bezier(mX1: number, mY1: number, mX2: number, mY2: number): (x: number) => number {
|
||
if (!(0 <= mX1 && mX1 <= 1 && 0 <= mX2 && mX2 <= 1)) {
|
||
throw new Error('bezier x values must be in [0, 1] range');
|
||
}
|
||
|
||
if (mX1 === mY1 && mX2 === mY2) {
|
||
return LinearEasing;
|
||
}
|
||
|
||
// Precompute samples table
|
||
const sampleValues: Float32Array | number[] = float32ArraySupported
|
||
? new Float32Array(kSplineTableSize)
|
||
: new Array(kSplineTableSize);
|
||
for (let i = 0; i < kSplineTableSize; ++i) {
|
||
sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2);
|
||
}
|
||
|
||
function getTForX(aX: number) {
|
||
let intervalStart = 0.0;
|
||
let currentSample = 1;
|
||
const lastSample = kSplineTableSize - 1;
|
||
|
||
for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {
|
||
intervalStart += kSampleStepSize;
|
||
}
|
||
--currentSample;
|
||
|
||
// Interpolate to provide an initial guess for t
|
||
const dist =
|
||
(aX - sampleValues[currentSample]) /
|
||
(sampleValues[currentSample + 1] - sampleValues[currentSample]);
|
||
const guessForT = intervalStart + dist * kSampleStepSize;
|
||
|
||
const initialSlope = getSlope(guessForT, mX1, mX2);
|
||
if (initialSlope >= NEWTON_MIN_SLOPE) {
|
||
return newtonRaphsonIterate(aX, guessForT, mX1, mX2);
|
||
} else if (initialSlope === 0.0) {
|
||
return guessForT;
|
||
} else {
|
||
return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2);
|
||
}
|
||
}
|
||
|
||
return function BezierEasing(x: number) {
|
||
// Because JavaScript number are imprecise, we should guarantee the extremes are right.
|
||
if (x === 0 || x === 1) {
|
||
return x;
|
||
}
|
||
return calcBezier(getTForX(x), mY1, mY2);
|
||
};
|
||
}
|
||
|
||
function getNormalizedTimeForProgress(
|
||
targetProgress: number,
|
||
easingFunction: (t: number) => number,
|
||
tolerance = 0.0001,
|
||
maxIterations = 100
|
||
): number {
|
||
if (targetProgress <= 0) return 0;
|
||
if (targetProgress >= 1) return 1;
|
||
|
||
let minT = 0;
|
||
let maxT = 1;
|
||
let t = 0.5;
|
||
|
||
for (let i = 0; i < maxIterations; i++) {
|
||
const currentProgress = easingFunction(t);
|
||
const error = currentProgress - targetProgress;
|
||
|
||
if (Math.abs(error) < tolerance) {
|
||
return t;
|
||
}
|
||
|
||
if (error < 0) {
|
||
minT = t;
|
||
} else {
|
||
maxT = t;
|
||
}
|
||
t = (minT + maxT) / 2;
|
||
}
|
||
return t;
|
||
}
|
||
// --- End of bezier-easing code ---
|
||
|
||
import { Button } from '$lib/components/ui/button';
|
||
import { Input } from '$lib/components/ui/input';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle
|
||
} from '$lib/components/ui/card';
|
||
import confetti from 'canvas-confetti';
|
||
import { toast } from 'svelte-sonner';
|
||
import { formatValue, playSound, showConfetti } from '$lib/utils';
|
||
import { volumeSettings } from '$lib/stores/volume-settings';
|
||
import { onMount } from 'svelte';
|
||
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
|
||
|
||
interface CoinflipResult {
|
||
won: boolean;
|
||
result: 'heads' | 'tails';
|
||
newBalance: number;
|
||
payout: number;
|
||
amountWagered: number;
|
||
}
|
||
|
||
const cssEaseInOut = bezier(0.42, 0, 0.58, 1.0);
|
||
const MAX_BET_AMOUNT = 1000000;
|
||
|
||
let {
|
||
balance = $bindable(),
|
||
onBalanceUpdate
|
||
}: {
|
||
balance: number;
|
||
onBalanceUpdate?: (newBalance: number) => void;
|
||
} = $props();
|
||
|
||
let betAmount = $state(10);
|
||
let betAmountDisplay = $state('10');
|
||
let selectedSide = $state('heads');
|
||
let isFlipping = $state(false);
|
||
let coinRotation = $state(0);
|
||
let lastResult = $state<CoinflipResult | null>(null);
|
||
let activeSoundTimeouts = $state<NodeJS.Timeout[]>([]);
|
||
|
||
let canBet = $derived(
|
||
betAmount > 0 && betAmount <= balance && betAmount <= MAX_BET_AMOUNT && !isFlipping
|
||
);
|
||
|
||
function selectSide(side: string) {
|
||
if (!isFlipping) {
|
||
selectedSide = side;
|
||
}
|
||
}
|
||
|
||
function setBetAmount(amount: number) {
|
||
const clampedAmount = Math.min(amount, Math.min(balance, MAX_BET_AMOUNT));
|
||
if (clampedAmount >= 0) {
|
||
betAmount = clampedAmount;
|
||
betAmountDisplay = clampedAmount.toLocaleString();
|
||
}
|
||
}
|
||
|
||
function handleBetAmountInput(event: Event) {
|
||
const target = event.target as HTMLInputElement;
|
||
const value = target.value.replace(/,/g, '');
|
||
const numValue = parseFloat(value) || 0;
|
||
const clampedValue = Math.min(numValue, Math.min(balance, MAX_BET_AMOUNT));
|
||
|
||
betAmount = clampedValue;
|
||
betAmountDisplay = target.value;
|
||
}
|
||
|
||
function handleBetAmountBlur() {
|
||
betAmountDisplay = betAmount.toLocaleString();
|
||
}
|
||
|
||
async function flipCoin() {
|
||
if (!canBet) return;
|
||
|
||
isFlipping = true;
|
||
lastResult = null;
|
||
|
||
activeSoundTimeouts.forEach(clearTimeout);
|
||
activeSoundTimeouts = [];
|
||
|
||
try {
|
||
const response = await fetch('/api/gambling/coinflip', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
side: selectedSide,
|
||
amount: betAmount
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error || 'Failed to place bet');
|
||
}
|
||
|
||
const resultData: CoinflipResult = await response.json();
|
||
|
||
const baseSpinsDegrees = 1800;
|
||
const currentRotationValue = coinRotation;
|
||
|
||
let rotationDeltaForThisFlip = baseSpinsDegrees;
|
||
|
||
const faceAfterBaseSpins =
|
||
(currentRotationValue + baseSpinsDegrees) % 360 < 180 ? 'heads' : 'tails';
|
||
|
||
if (faceAfterBaseSpins !== resultData.result) {
|
||
rotationDeltaForThisFlip += 180;
|
||
}
|
||
|
||
if (rotationDeltaForThisFlip === 0) {
|
||
rotationDeltaForThisFlip = 360;
|
||
}
|
||
|
||
coinRotation = currentRotationValue + rotationDeltaForThisFlip;
|
||
|
||
const animationDuration = 2000;
|
||
|
||
if (rotationDeltaForThisFlip >= 180) {
|
||
const numHalfSpins = Math.floor(rotationDeltaForThisFlip / 180);
|
||
|
||
for (let i = 1; i <= numHalfSpins; i++) {
|
||
const targetEasedProgress = (i * 180) / rotationDeltaForThisFlip;
|
||
const normalizedTime = getNormalizedTimeForProgress(targetEasedProgress, cssEaseInOut);
|
||
const timeToPlaySound = normalizedTime * animationDuration;
|
||
|
||
const timeoutId = setTimeout(() => {
|
||
playSound('flip');
|
||
}, timeToPlaySound);
|
||
activeSoundTimeouts.push(timeoutId);
|
||
}
|
||
}
|
||
|
||
setTimeout(() => {
|
||
balance = resultData.newBalance;
|
||
lastResult = resultData;
|
||
onBalanceUpdate?.(resultData.newBalance);
|
||
|
||
if (resultData.won) {
|
||
showConfetti(confetti);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
isFlipping = false;
|
||
if (!resultData.won) {
|
||
playSound('lose');
|
||
}
|
||
}, 500);
|
||
}, animationDuration);
|
||
} catch (error) {
|
||
console.error('Coinflip error:', error);
|
||
toast.error('Bet failed', {
|
||
description: error instanceof Error ? error.message : 'Unknown error occurred'
|
||
});
|
||
isFlipping = false;
|
||
activeSoundTimeouts.forEach(clearTimeout);
|
||
activeSoundTimeouts = [];
|
||
}
|
||
}
|
||
|
||
onMount(async () => {
|
||
volumeSettings.load();
|
||
|
||
try {
|
||
const data = await fetchPortfolioSummary();
|
||
if (data) {
|
||
balance = data.baseCurrencyBalance;
|
||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch balance:', error);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Coinflip</CardTitle>
|
||
<CardDescription>Choose heads or tails and double your money!</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<!-- Main Layout: Coin/Balance Left, Controls Right -->
|
||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||
<!-- Left Side: Coin, Balance, and Result -->
|
||
<div class="flex flex-col space-y-4">
|
||
<!-- Balance Display -->
|
||
<div class="text-center">
|
||
<p class="text-muted-foreground text-sm">Balance</p>
|
||
<p class="text-2xl font-bold">{formatValue(balance)}</p>
|
||
</div>
|
||
|
||
<!-- Coin Animation -->
|
||
<div class="flex justify-center">
|
||
<div class="coin-container">
|
||
<div class="coin" style="transform: rotateY({coinRotation}deg)">
|
||
<div class="coin-face coin-heads">
|
||
<img
|
||
src="/facedev/avif/bliptext.avif"
|
||
alt="Heads"
|
||
class="h-32 w-32 object-contain"
|
||
/>
|
||
</div>
|
||
<div class="coin-face coin-tails">
|
||
<img
|
||
src="/facedev/avif/wattesigma.avif"
|
||
alt="Tails"
|
||
class="h-32 w-32 object-contain"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Result Display (Reserve Space) -->
|
||
<div class="flex items-center justify-center text-center">
|
||
{#if lastResult && !isFlipping}
|
||
<div class="bg-muted/50 w-full rounded-lg p-3">
|
||
{#if lastResult.won}
|
||
<p class="text-success font-semibold">WIN</p>
|
||
<p class="text-sm">
|
||
Won {formatValue(lastResult.payout)} on {lastResult.result}
|
||
</p>
|
||
{:else}
|
||
<p class="text-destructive font-semibold">LOSS</p>
|
||
<p class="text-sm">
|
||
Lost {formatValue(lastResult.amountWagered)} on {lastResult.result}
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Side: Betting Controls -->
|
||
<div class="space-y-4">
|
||
<!-- Side Selection (Inline) -->
|
||
<div>
|
||
<div class="mb-2 block text-sm font-medium">Choose Side</div>
|
||
<div class="flex gap-3">
|
||
<Button
|
||
variant={selectedSide === 'heads' ? 'default' : 'outline'}
|
||
onclick={() => selectSide('heads')}
|
||
disabled={isFlipping}
|
||
class="side-button h-16 flex-1"
|
||
>
|
||
<div class="text-center">
|
||
<img
|
||
src="/facedev/avif/bliptext.avif"
|
||
alt="Heads"
|
||
class="mx-auto mb-1 h-8 w-8 object-contain"
|
||
/>
|
||
<div>Heads</div>
|
||
</div>
|
||
</Button>
|
||
<Button
|
||
variant={selectedSide === 'tails' ? 'default' : 'outline'}
|
||
onclick={() => selectSide('tails')}
|
||
disabled={isFlipping}
|
||
class="side-button h-16 flex-1"
|
||
>
|
||
<div class="text-center">
|
||
<img
|
||
src="/facedev/avif/wattesigma.avif"
|
||
alt="Tails"
|
||
class="mx-auto mb-1 h-8 w-8 object-contain"
|
||
/>
|
||
<div>Tails</div>
|
||
</div>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bet Amount -->
|
||
<div>
|
||
<label for="bet-amount" class="mb-2 block text-sm font-medium">Bet Amount</label>
|
||
<Input
|
||
id="bet-amount"
|
||
type="text"
|
||
value={betAmountDisplay}
|
||
oninput={handleBetAmountInput}
|
||
onblur={handleBetAmountBlur}
|
||
disabled={isFlipping}
|
||
placeholder="Enter bet amount"
|
||
/>
|
||
<p class="text-muted-foreground mt-1 text-xs">
|
||
Max bet: {MAX_BET_AMOUNT.toLocaleString()}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Percentage Quick Actions -->
|
||
<div>
|
||
<div class="grid grid-cols-4 gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() =>
|
||
setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.25))}
|
||
disabled={isFlipping}>25%</Button
|
||
>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.5))}
|
||
disabled={isFlipping}>50%</Button
|
||
>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() =>
|
||
setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.75))}
|
||
disabled={isFlipping}>75%</Button
|
||
>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT)))}
|
||
disabled={isFlipping}>Max</Button
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Flip Button -->
|
||
<Button class="h-12 w-full text-lg" onclick={flipCoin} disabled={!canBet}>
|
||
{isFlipping ? 'Flipping...' : 'Flip'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<style>
|
||
.coin-container {
|
||
position: relative;
|
||
width: 8rem; /* 128px */
|
||
height: 8rem; /* 128px */
|
||
perspective: 1000px;
|
||
}
|
||
|
||
.coin {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
transform-style: preserve-3d;
|
||
transition: transform 2s ease-in-out;
|
||
}
|
||
|
||
.coin-face {
|
||
position: absolute;
|
||
width: 100%;
|
||
height: 100%;
|
||
backface-visibility: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.coin-heads {
|
||
transform: rotateY(0deg);
|
||
}
|
||
|
||
.coin-tails {
|
||
transform: rotateY(180deg);
|
||
}
|
||
|
||
:global(.side-button) {
|
||
box-sizing: border-box !important;
|
||
border: 2px solid transparent !important;
|
||
}
|
||
|
||
:global(.side-button[data-variant='outline']) {
|
||
border-color: hsl(var(--border)) !important;
|
||
}
|
||
|
||
:global(.side-button[data-variant='default']) {
|
||
border-color: hsl(var(--primary)) !important;
|
||
}
|
||
</style>
|