This repository has been archived on 2025-08-19. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
coinstorge/website/src/lib/components/self/games/Coinflip.svelte

534 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>