feat: 实现桌面控制组件

This commit is contained in:
JiaJun
2026-04-30 19:04:19 +08:00
parent 36ce93d8d0
commit 9ee681168e
60 changed files with 1246 additions and 247 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
src/assets/game/add.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
src/assets/game/arrow.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
src/assets/game/chip1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
src/assets/game/chip2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
src/assets/game/chip3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
src/assets/game/chip4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
src/assets/game/chip5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
src/assets/game/chip6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
src/assets/game/reduce.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

BIN
src/assets/system/fire.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
src/assets/system/lock.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 268 KiB

View File

@@ -7,9 +7,7 @@ import {
useMemo,
useState,
} from 'react'
const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
import { cn } from '@/lib/untils'
export interface SmartImageProps
extends Omit<
@@ -89,7 +87,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
return (
<div
className={cx('relative overflow-hidden', className)}
className={cn('relative overflow-hidden', className)}
style={aspectRatio ? { aspectRatio } : undefined}
>
{placeholderSrc ? (
@@ -97,7 +95,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
src={placeholderSrc}
alt=""
aria-hidden="true"
className={cx(
className={cn(
'pointer-events-none absolute inset-0 h-full w-full object-cover transition duration-300',
shouldShowVisualPlaceholder
? 'scale-105 opacity-100 blur-xl'
@@ -109,7 +107,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
{showSkeleton && shouldShowVisualPlaceholder ? (
<div
aria-hidden="true"
className={cx(
className={cn(
'absolute inset-0 animate-pulse bg-gradient-to-br from-slate-800 via-slate-700/70 to-slate-800',
skeletonClassName,
)}
@@ -125,8 +123,8 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
decoding={decoding ?? 'async'}
draggable={draggable}
fetchPriority={resolvedFetchPriority}
className={cx(
'relative z-[1] h-full w-full object-cover transition duration-300',
className={cn(
'relative z-10 h-full w-full object-cover transition duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
imgClassName,
)}
@@ -162,7 +160,7 @@ export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
{showFallback
? (fallback ?? (
<div
className={cx(
className={cn(
'absolute inset-0 flex items-center justify-center bg-slate-950/80 px-4 text-center text-xs font-medium tracking-[0.12em] text-slate-300 uppercase',
fallbackClassName,
)}

View File

@@ -1,3 +1,14 @@
import { Repeat2, Settings, Trash2 } from 'lucide-react'
import chip1 from '@/assets/game/chip1.webp'
import chip2 from '@/assets/game/chip2.webp'
import chip3 from '@/assets/game/chip3.webp'
import chip4 from '@/assets/game/chip4.webp'
import chip5 from '@/assets/game/chip5.webp'
import chip6 from '@/assets/game/chip6.webp'
import controlLeft from '@/assets/game/control-left.webp'
import controlMid from '@/assets/game/control-mid.webp'
import controlRight from '@/assets/game/control-right.webp'
/** @description 应用启动阶段使用的根节点常量。 */
export const APP_ROOT_ELEMENT_ID = 'root'
@@ -42,3 +53,18 @@ export const I18N_LANGUAGE_STORAGE_KEY = 'app-language'
/** @description 桌面端布局切换起始断点。 */
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
export const CHIP_OPTIONS = [
{ id: 'chip-1', value: 1, src: chip1 },
{ id: 'chip-2', value: 5, src: chip2 },
{ id: 'chip-3', value: 10, src: chip3 },
{ id: 'chip-4', value: 25, src: chip4 },
{ id: 'chip-5', value: 50, src: chip5 },
{ id: 'chip-6', value: 100, src: chip6 },
]
export const ACTION_OPTIONS = [
{ id: 'clear', label: 'Clear', Icon: Trash2, bg: controlLeft },
{ id: 'repeat', label: 'Repeat', Icon: Repeat2, bg: controlMid },
{ id: 'auto-spin', label: 'Auto-Spin', Icon: Settings, bg: controlRight },
]

View File

@@ -1,7 +1,5 @@
import { SmartImage } from '@/components/smart-image'
const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
import { cn } from '@/lib/untils'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -36,7 +34,12 @@ export function DesktopAnimal({
onSelect,
}: DesktopAnimalProps) {
return (
<section className={cx('w-full grid grid-cols-6 gap-[5px]', className)}>
<section
className={cn(
'grid w-full grid-cols-6 gap-design-5 common-neon-inset',
className,
)}
>
{animalImageList.map((item) => {
const isActive = item.id === activeId
@@ -45,7 +48,7 @@ export function DesktopAnimal({
key={item.id}
type="button"
onClick={() => onSelect?.(item.id)}
className={cx(
className={cn(
'flex flex-col items-center transition',
'cursor-pointer',
isActive &&
@@ -56,8 +59,8 @@ export function DesktopAnimal({
<SmartImage
src={item.url}
alt={`animal-${item.id}`}
className={cx(
'h-[112px] w-[223px] object-contain rounded-2xl',
className={cn(
'h-design-112 w-design-223 rounded-2xl object-contain',
imageClassName,
)}
/>

View File

@@ -1,3 +1,371 @@
import { motion } from 'motion/react'
import { useCallback, useState } from 'react'
import add from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp'
import confirmBg from '@/assets/game/confirm-bg.png'
import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import reduce from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { ACTION_OPTIONS, CHIP_OPTIONS } from '@/constants'
import { cn } from '@/lib/untils.ts'
export function DesktopControl() {
return <div className={'w-full'}>DesktopControl</div>
const [chips, setChips] = useState(CHIP_OPTIONS)
const [selectedChipId, setSelectedChipId] = useState(
CHIP_OPTIONS[CHIP_OPTIONS.length - 1]?.id ?? '',
)
const [clickedId, setClickedId] = useState<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [confirmClicked, setConfirmClicked] = useState(false)
const selectedChip =
chips.find((chip) => chip.id === selectedChipId) ?? CHIP_OPTIONS[0]
const handleChipClick = (chipId: string) => {
setSelectedChipId(chipId)
setChips((current) => {
const next = [...current]
const index = next.findIndex((chip) => chip.id === chipId)
if (index === -1 || index === next.length - 1) {
return next
}
const [selected] = next.splice(index, 1)
next.push(selected)
return next
})
}
const handleActionClick = useCallback((id: string) => {
setClickedId(id)
setTimeout(() => {
setClickedId(null)
setHidingId(id)
setTimeout(() => {
setHidingId(null)
}, 180)
}, 200)
}, [])
const handleConfirmClick = useCallback(() => {
setConfirmClicked(true)
setTimeout(() => {
setConfirmClicked(false)
}, 200)
}, [])
return (
<div
className={
'flex h-design-100 w-full items-center gap-design-16 overflow-hidden'
}
>
<div
className={
'flex flex-col items-center gap-1 justify-center h-full w-design-110 shrink-0 bg-center bg-no-repeat'
}
style={{
backgroundImage: `url(${leftBottomBg})`,
backgroundSize: '100% 100%',
}}
>
<div className={'flex flex-col items-center justify-center'}>
<div>TREBD</div>
<div>MAP</div>
</div>
<SmartImage
src={arrow}
alt={`animal`}
className={'w-design-20 h-design-10'}
/>
</div>
<div
className={
'desktop-control-chip relative z-10 flex h-full w-design-710 shrink-0 items-center gap-design-10 pl-design-20 pr-design-40 bg-center bg-no-repeat'
}
style={{
backgroundImage: `url(${chipBg})`,
backgroundSize: '100% 100%',
}}
>
<div
style={{
backgroundImage: `url(${chipLineBg})`,
backgroundSize: '100% 100%',
}}
className={
'flex min-w-0 flex-1 items-center gap-design-10 overflow-visible'
}
>
{chips.map((chip) => {
const isSelected = chip.id === selectedChipId
return (
<motion.button
key={chip.id}
layout
type="button"
onClick={() => handleChipClick(chip.id)}
whileTap={{ scale: 0.94 }}
transition={{
layout: {
type: 'spring',
stiffness: 420,
damping: 32,
},
duration: 0.18,
}}
className={
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
}
>
<motion.span
animate={
isSelected
? {
opacity: [0.22, 0.5, 0.22],
scaleX: [0.82, 1.02, 0.82],
scaleY: [0.42, 0.56, 0.42],
}
: {
opacity: 0.32,
scaleX: 0.88,
scaleY: 0.48,
}
}
transition={
isSelected
? {
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.2, ease: 'easeOut' }
}
className={
'pointer-events-none absolute bottom-[calc(var(--design-unit)*-4)] left-1/2 h-design-16 w-design-46 -translate-x-1/2 rounded-full bg-[rgba(0,0,0,0.48)] blur-[6px]'
}
/>
<motion.span
animate={
isSelected
? {
opacity: [0.72, 1, 0.72],
scale: [0.96, 1.04, 0.96],
boxShadow: [
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 8px rgba(245, 200, 107, 0.44), 0 0 18px rgba(245, 200, 107, 0.22), inset 0 0 8px rgba(255, 214, 110, 0.18)',
'0 0 0 1px rgba(245, 200, 107, 0.96), 0 0 14px rgba(245, 200, 107, 0.78), 0 0 28px rgba(245, 200, 107, 0.42), inset 0 0 12px rgba(255, 214, 110, 0.32)',
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 8px rgba(245, 200, 107, 0.44), 0 0 18px rgba(245, 200, 107, 0.22), inset 0 0 8px rgba(255, 214, 110, 0.18)',
],
}
: {
opacity: 0,
scale: 0.92,
boxShadow: '0 0 0 0 rgba(0,0,0,0)',
}
}
transition={
isSelected
? {
duration: 1.6,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.22, ease: 'easeOut' }
}
className={
'pointer-events-none absolute inset-0 rounded-full'
}
/>
<motion.div
animate={
isSelected
? {
y: [-1, -3, -1],
scale: [1.02, 1.06, 1.02],
filter: [
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
'drop-shadow(0 10px 14px rgba(245, 200, 107, 0.22))',
'drop-shadow(0 8px 10px rgba(0,0,0,0.18))',
],
}
: {
y: 0,
scale: 1,
filter:
'drop-shadow(0 6px 10px rgba(0,0,0,0.34)) drop-shadow(0 2px 4px rgba(255,255,255,0.08))',
}
}
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
className={'relative z-[1]'}
>
<motion.img
src={chip.src}
alt={`chip-${chip.value}`}
draggable={false}
className={'h-design-70 w-design-70 object-contain'}
/>
</motion.div>
</motion.button>
)
})}
</div>
<div
className={
'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3'
}
>
<SmartImage
src={add}
alt={`add`}
className={'w-design-40 h-design-40'}
/>
<div
className={'w-design-80 h-full flex items-center justify-center'}
>
{selectedChip.value}
</div>
<SmartImage
src={reduce}
alt={`reduce`}
className={'w-design-40 h-design-40'}
/>
</div>
</div>
<div
className={
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
}
style={{
backgroundImage: `url(${totalBg})`,
backgroundSize: '100% 100%',
}}
>
<div>SELECTED:3/5</div>
<div>Total Bet150</div>
</div>
<div
className={cn(
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
)}
style={{
backgroundImage: `url(${controlBg})`,
backgroundSize: '100% 100%',
}}
>
{ACTION_OPTIONS.map(({ id, label, Icon, bg }) => {
const isClicked = clickedId === id
const isHiding = hidingId === id
const showBg = isClicked || isHiding
return (
<motion.button
key={id}
type="button"
onClick={() => handleActionClick(id)}
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.96 }}
className={cn(
'relative flex h-full flex-1 cursor-pointer items-center justify-center overflow-hidden',
{ '-translate-x-1.5': id === 'auto-spin' },
)}
>
{showBg && (
<motion.span
initial={{ opacity: 0 }}
animate={isClicked ? { opacity: 1 } : { opacity: 0 }}
transition={{
duration: isClicked ? 0.15 : 0.18,
ease: 'easeOut',
}}
className={cn(
'pointer-events-none absolute bg-center inset-0 bg-no-repeat h-design-100',
)}
style={{
backgroundImage: `url(${bg})`,
backgroundSize:
id === 'clear'
? '110% 90%'
: id === 'repeat'
? '98% 80%'
: '96% 80%',
}}
/>
)}
<motion.div
animate={
showBg
? {
y: -1,
scale: 1.01,
}
: {
y: 0,
scale: 1,
}
}
transition={{
duration: showBg ? 0.22 : 0.18,
ease: 'easeOut',
}}
className={
'relative z-10 flex flex-col items-center justify-center'
}
>
<Icon
size={20}
strokeWidth={2.2}
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
/>
<div className={'mt-design-6 text-design-14 leading-none'}>
{label}
</div>
</motion.div>
</motion.button>
)
})}
</div>
<motion.button
type="button"
onClick={handleConfirmClick}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.96 }}
className={cn(
'relative z-10 h-full w-design-260 shrink-0 cursor-pointer bg-center bg-no-repeat flex items-center justify-center text-design-32 font-bold',
)}
style={{
backgroundImage: `url(${confirmBg})`,
backgroundSize: '100% 100%',
}}
>
{confirmClicked && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
style={{
backgroundImage: `url(${confirmBg})`,
backgroundSize: '100% 100%',
}}
/>
)}
<motion.span
animate={confirmClicked ? { opacity: 0, y: 2 } : { opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
className="relative"
>
confirm
</motion.span>
</motion.button>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import { useEffect, useMemo, useState } from 'react'
import { cn } from '@/lib/untils'
dayjs.extend(duration)
function formatCountdown(remainingMs: number) {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
const countdown = dayjs.duration(totalSeconds, 'seconds')
const minutes = String(Math.floor(countdown.asMinutes())).padStart(2, '0')
const seconds = String(countdown.seconds()).padStart(2, '0')
return `${minutes}:${seconds}`
}
interface DesktopCountdownProps {
className?: string
initialMs?: number
initialSeconds?: number
onComplete?: () => void
}
export function DesktopCountdown({
className,
initialMs,
initialSeconds,
onComplete,
}: DesktopCountdownProps) {
const initialCountdownMs = useMemo(() => {
if (typeof initialMs === 'number') {
return Math.max(0, initialMs)
}
if (typeof initialSeconds === 'number') {
return Math.max(0, initialSeconds * 1000)
}
return 30_000
}, [initialMs, initialSeconds])
const [remainingMs, setRemainingMs] = useState(initialCountdownMs)
useEffect(() => {
setRemainingMs(initialCountdownMs)
}, [initialCountdownMs])
useEffect(() => {
if (initialCountdownMs <= 0) {
setRemainingMs(0)
onComplete?.()
return
}
const startedAt = Date.now()
const timer = window.setInterval(() => {
const elapsedMs = Date.now() - startedAt
const nextRemainingMs = Math.max(0, initialCountdownMs - elapsedMs)
setRemainingMs(nextRemainingMs)
if (nextRemainingMs === 0) {
window.clearInterval(timer)
onComplete?.()
}
}, 250)
return () => {
window.clearInterval(timer)
}
}, [initialCountdownMs, onComplete])
return (
<div
className={cn(
'font-countdown text-design-48 leading-none tracking-[0.08em] text-[#4BFFFE]',
className,
)}
>
{formatCountdown(remainingMs)}
</div>
)
}

View File

@@ -1,3 +1,183 @@
import historyBg from '@/assets/system/history-bg.png'
export function DesktopGameHistory() {
return <div className={'w-full'}>DesktopGameHistory</div>
const data = [
{
order_no: 'BET202604290001',
period_no: '202604290101',
numbers: [3, 8, 12],
bet_amount: '100.00',
total_amount: '100.00',
result_number: 8,
win_amount: '330.00',
status: 'won',
create_time: 1745881200,
},
{
order_no: 'BET202604290002',
period_no: '202604290102',
numbers: [5],
bet_amount: '50.00',
total_amount: '50.00',
result_number: 11,
win_amount: '0.00',
status: 'lost',
create_time: 1745882100,
},
{
order_no: 'BET202604290003',
period_no: '202604290103',
numbers: [1, 7],
bet_amount: '88.00',
total_amount: '88.00',
result_number: 7,
win_amount: '176.00',
status: 'won',
create_time: 1745883000,
},
{
order_no: 'BET202604290004',
period_no: '202604290104',
numbers: [9, 10, 15],
bet_amount: '120.00',
total_amount: '120.00',
result_number: 4,
win_amount: '0.00',
status: 'settled',
create_time: 1745883900,
},
{
order_no: 'BET202604290005',
period_no: '202604290105',
numbers: [6],
bet_amount: '66.00',
total_amount: '66.00',
result_number: null,
win_amount: '0.00',
status: 'pending',
create_time: 1745884800,
},
{
order_no: 'BET202604290006',
period_no: '202604290106',
numbers: [2, 14],
bet_amount: '200.00',
total_amount: '200.00',
result_number: 14,
win_amount: '400.00',
status: 'won',
create_time: 1745885700,
},
{
order_no: 'BET202604290007',
period_no: '202604290107',
numbers: [13],
bet_amount: '30.00',
total_amount: '30.00',
result_number: 13,
win_amount: '99.00',
status: 'won',
create_time: 1745886600,
},
{
order_no: 'BET202604290008',
period_no: '202604290108',
numbers: [4, 16],
bet_amount: '150.00',
total_amount: '150.00',
result_number: 1,
win_amount: '0.00',
status: 'lost',
create_time: 1745887500,
},
{
order_no: 'BET202604290009',
period_no: '202604290109',
numbers: [11, 18, 20],
bet_amount: '300.00',
total_amount: '300.00',
result_number: null,
win_amount: '0.00',
status: 'pending',
create_time: 1745888400,
},
{
order_no: 'BET202604290010',
period_no: '202604290110',
numbers: [17],
bet_amount: '80.00',
total_amount: '80.00',
result_number: 17,
win_amount: '264.00',
status: 'won',
create_time: 1745889300,
},
]
return (
<div
className="desktop-game-history-glow flex h-full min-h-0 w-full flex-col items-center overflow-hidden bg-center bg-no-repeat py-2"
style={{
backgroundImage: `url(${historyBg})`,
backgroundSize: '100% 100%',
}}
>
<div
className={
'relative z-20 flex h-design-50 shrink-0 items-center justify-center text-design-30 text-[#D5FBFF]'
}
>
History
</div>
<div
className={
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
}
>
{data.map((item) => {
return (
<div
key={item.order_no}
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
>
{item.status}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>Round ID: </span>
<span className={'text-[#C0E7EB]'}>{item.order_no}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Animals Bet: </span>
<span>{item.numbers.join(', ')}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>Total Bet Amount: </span>
<span className={'text-[#FFE375]'}>{item.bet_amount}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}> Winning Result:</span>
<span className={'text-[#FF7575]'}>
{' '}
{item.result_number === null ? '--' : item.result_number}
</span>
</div>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -7,64 +7,82 @@ import { SmartImage } from '@/components/smart-image.tsx'
export function DesktopHeader() {
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
<div className="h-[70px] w-full flex items-center px-[12px]">
<div className="h-full flex px-[10px] justify-center items-center border-r border-[rgba(128,223,231,0.65)]">
<div className="flex h-design-70 w-full items-center px-design-12">
<div className="flex h-full w-design-375 items-center justify-center border-r border-[rgba(128,223,231,0.65)] px-design-10">
<SmartImage
src={logo}
alt="logo"
priority
className="w-[320px] h-[40px]"
className="h-design-40 w-design-320"
/>
</div>
<div className="w-[120px] flex justify-center items-center gap-[10px] h-full border-r border-[rgba(128,223,231,0.65)]">
<div className="flex h-full w-design-130 items-center justify-center gap-design-10 border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={wifi}
alt="wifi"
priority
className="w-[28px] h-[20px]"
className="h-design-20 w-design-28"
/>
<div className={'text-[#74FF69] text-[20px]'}>
24 <span className={'text-[16px]'}>ms</span>
<div className={'text-[#74FF69] text-design-20'}>
24 <span className={'text-design-16'}>ms</span>
</div>
</div>
<div className="w-[150px] flex flex-col justify-center items-center gap-[5px] h-full border-r border-[rgba(128,223,231,0.65)]">
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
<div>System Time</div>
<div>20:05:12 GMT+08</div>
</div>
<div className="flex-1 flex items-center justify-around gap-[10px] h-full px-[10px] text-[#D5FBFF]">
<div className={'flex items-center gap-[5px] common-neon-inset'}>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 px-design-40 text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>Rules & Ddds</div>
</div>
<div className={'flex items-center gap-[5px] common-neon-inset'}>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<Mail color={'#57B8BF'} size={16} />
<div>Pesan</div>
</div>
<div className={'flex items-center gap-[5px] common-neon-inset'}>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<Volume2 color={'#57B8BF'} size={16} />
<div>BGM</div>
</div>
<div className={'flex items-center gap-[5px] common-neon-inset'}>
<div
className={
'min-w-design-120 common-neon-inset flex items-center justify-center gap-design-10 !px-design-16'
}
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>ID</div>
</div>
</div>
<div className={'flex items-center justify-center px-design-35'}>
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-[20px] top-0 z-20 w-[50px] h-[50px]"
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset !py-[20px] flex justify-end items-center w-[160px] h-[36px]'
'common-neon-inset !py-design-20 flex h-design-36 w-design-160 items-center justify-end'
}
>
Biomond Balance
@@ -75,11 +93,11 @@ export function DesktopHeader() {
src={avatar}
alt="avatar"
priority
className="absolute left-[20px] top-0 z-20 w-[50px] h-[50px]"
className="absolute left-design-20 top-design-0 z-20 h-design-50 w-design-50"
/>
<div
className={
'common-neon-inset !py-[20px] flex items-center justify-end box-border w-[160px] h-[36px]'
'common-neon-inset !py-design-20 box-border flex h-design-36 w-design-160 items-center justify-end'
}
>
Biomond Balance

View File

@@ -0,0 +1,54 @@
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
export function DesktopStatusLine() {
return (
<div className={'relative w-full flex flex-col text-design-22'}>
<div
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center"
style={{
backgroundImage: `url(${statusLine})`,
backgroundSize: '100% 100%',
}}
>
<div className={'flex-1 flex items-center justify-center'}>
<div>Odds: 1:33</div>
<div>Streak: X2</div>
<div>Limit: 100</div>
</div>
<div
className="w-design-360 h-[105px] font-countdown bg-no-repeat bg-center bg-contain flex items-center justify-center"
style={{ backgroundImage: `url(${statusCenter})` }}
>
<DesktopCountdown
initialSeconds={30}
onComplete={() => {
console.log('countdown finished')
}}
/>
</div>
<div className={'flex-1 flex items-center justify-center gap-10'}>
<div>Round ID:20241026120</div>
<div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-2'}>
<div
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
></div>
<div className={'text-[#78FF7F]'}>OPEN</div>
</div>
<div>(Menerima Taruhan)</div>
</div>
</div>
</div>
<div
className={
'absolute top-design-60 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
}
>
<DesktopTitle />
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { Megaphone } from 'lucide-react'
export function DesktopTitle() {
return (
<section className="common-neon-inset flex items-center text-[#FF970F] gap-[10px] !px-[20px] h-[40px]">
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]">
<Megaphone color={'#57B8BF'} />
<div>
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama

View File

@@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
import { cn } from '@/lib/untils'
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
@@ -45,7 +44,7 @@ function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
<button
type="button"
onClick={onClick}
className={cx(
className={cn(
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
actionToneClasses[tone],
)}
@@ -75,7 +74,7 @@ export function GameAnnouncementModal({
role="dialog"
aria-modal="true"
aria-labelledby="game-announcement-title"
className={cx(
className={cn(
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
toneClasses[tone],
)}

View File

@@ -3,14 +3,15 @@ import { useTranslation } from 'react-i18next'
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
import { GameAnnouncementModal } from '@/features/game/components'
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
import { useDocumentMetadata } from '@/lib/head/document-metadata'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const ENABLE_ANNOUNCEMENT_MODAL = false
export function GameRoutePage() {
export function EntryPage() {
const { t } = useTranslation()
const announcements = useGameSessionStore((state) => state.announcements)
const dismissAnnouncement = useGameSessionStore(
(state) => state.dismissAnnouncement,
@@ -22,6 +23,13 @@ export function GameRoutePage() {
)
const [isHydrating, setIsHydrating] = useState(true)
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') {
return false
}
return window.matchMedia('(max-width: 768px)').matches
})
const activeAnnouncement = useMemo(
() =>
@@ -69,12 +77,32 @@ export function GameRoutePage() {
}
}, [hydrateRound, hydrateSession])
useEffect(() => {
if (typeof window === 'undefined') {
return
}
const mediaQuery = window.matchMedia('(max-width: 768px)')
const syncLayout = (event?: MediaQueryListEvent) => {
setIsMobile(event?.matches ?? mediaQuery.matches)
}
syncLayout()
mediaQuery.addEventListener('change', syncLayout)
return () => {
mediaQuery.removeEventListener('change', syncLayout)
}
}, [])
return (
<section
aria-busy={isHydrating}
aria-label={t('game.lobbyTitle')}
className="flex min-h-0 flex-1"
className="flex min-h-0 flex-1 flex-col"
>
{isMobile ? <MobileEntry /> : <PcEntry />}
<GameAnnouncementModal
open={ENABLE_ANNOUNCEMENT_MODAL && Boolean(activeAnnouncement)}
eyebrow={t('game.modal.eyebrow')}

View File

@@ -0,0 +1,24 @@
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
export function MobileEntry() {
return (
<>
<div
className={'mx-auto my-design-10 w-[calc(100%-24*var(--design-unit))]'}
>
<DesktopTitle />
</div>
<div
className={
'mx-auto flex w-[calc(100%-24*var(--design-unit))] flex-col gap-design-10'
}
>
<DesktopGameHistory />
<DesktopAnimal />
</div>
</>
)
}

View File

@@ -0,0 +1,41 @@
import { DesktopHeader } from '@/features/game/components'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
export function PcEntry() {
return (
<>
<DesktopHeader />
<div
className={
'mx-auto mt-design-30 mb-design-60 w-[calc(100%-40*var(--design-unit))]'
}
>
<DesktopStatusLine />
</div>
<div className={'mx-auto w-[calc(100%-72*var(--design-unit))]'}>
<div className={'flex w-full items-start gap-design-10'}>
<div className={'flex-1'}>
<DesktopAnimal />
</div>
<div
className={
'flex h-[calc(var(--design-unit)*715)] min-h-0 w-design-370'
}
>
<DesktopGameHistory />
</div>
</div>
</div>
<div
className={'mx-auto mt-design-10 w-[calc(100%-40*var(--design-unit))]'}
>
<DesktopControl />
</div>
</>
)
}

6
src/lib/untils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { GameRoutePage } from '@/features/game/entry/game-route-page'
import { EntryPage } from '@/features/game/entry/entry-page.tsx'
export const Route = createFileRoute('/$lang/')({
component: GameRoutePage,
component: EntryPage,
})

View File

@@ -1,10 +1,6 @@
import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopHeader } from '@/features/game/components/desktop/desktop-header.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
export const Route = createFileRoute('/$lang')({
@@ -26,28 +22,6 @@ function LanguageLayout() {
if (!isValidLanguage) {
return <Navigate to="/$lang" params={{ lang: preferredLanguage }} replace />
}
return (
<div className="flex min-h-full flex-col w-full">
<DesktopHeader />
<div className={'mx-auto w-[calc(100%-40px)] my-[10px]'}>
<DesktopTitle />
</div>
<div className={'mx-auto w-[calc(100%-72px)]'}>
<div className={'w-full flex gap-[10px]'}>
<div className={'flex-1'}>
<DesktopAnimal />
</div>
<div className={'w-[200px]'}>
<DesktopGameHistory />
</div>
</div>
</div>
<main className="flex min-h-0 flex-1 flex-col">
<Outlet />
</main>
</div>
)
return <Outlet />
}

11
src/style/font.css Normal file
View File

@@ -0,0 +1,11 @@
@font-face {
font-family: "Countdown";
src: url("../assets/fonts/countdown.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
.font-countdown {
font-family: "Countdown", var(--font-sans), sans-serif;
}

View File

@@ -1,3 +1,4 @@
@import "./font.css";
@import "tailwindcss";
@theme {
@@ -5,6 +6,7 @@
"Inter", "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
--font-mono:
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
--font-countdown: "Countdown", "Inter", "SF Pro Display", sans-serif;
--color-game-bg: #07111f;
--color-game-surface: #0d1b2d;
--color-game-surface-strong: #12253d;
@@ -18,9 +20,97 @@
0 20px 60px rgba(2, 6, 23, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
:root {
--design-unit: calc(100vw / 1920);
}
@media (max-width: 768px) {
:root {
--design-unit: calc(100vw / 375);
}
}
@utility w-design-* {
width: calc(var(--design-unit) * --value(integer));
}
@utility h-design-* {
height: calc(var(--design-unit) * --value(integer));
}
@utility min-w-design-* {
min-width: calc(var(--design-unit) * --value(integer));
}
@utility max-w-design-* {
max-width: calc(var(--design-unit) * --value(integer));
}
@utility text-design-* {
font-size: calc(var(--design-unit) * --value(integer));
}
@utility gap-design-* {
gap: calc(var(--design-unit) * --value(integer));
}
@utility px-design-* {
padding-inline: calc(var(--design-unit) * --value(integer));
}
@utility p-design-* {
padding: calc(var(--design-unit) * --value(integer));
}
@utility mt-design-* {
margin-top: calc(var(--design-unit) * --value(integer));
}
@utility mb-design-* {
margin-bottom: calc(var(--design-unit) * --value(integer));
}
@utility ml-design-* {
margin-left: calc(var(--design-unit) * --value(integer));
}
@utility mr-design-* {
margin-right: calc(var(--design-unit) * --value(integer));
}
@utility mx-design-* {
margin-left: calc(var(--design-unit) * --value(integer));
margin-right: calc(var(--design-unit) * --value(integer));
}
@utility py-design-* {
padding-top: calc(var(--design-unit) * --value(integer));
padding-bottom: calc(var(--design-unit) * --value(integer));
}
@utility pl-design-* {
padding-left: calc(var(--design-unit) * --value(integer));
}
@utility pr-design-* {
padding-right: calc(var(--design-unit) * --value(integer));
}
@utility my-design-* {
margin-block: calc(var(--design-unit) * --value(integer));
}
@utility left-design-* {
left: calc(var(--design-unit) * --value(integer));
}
@utility top-design-* {
top: calc(var(--design-unit) * --value(integer));
}
@layer base {
html {
@apply h-full w-full;
@apply h-full w-full text-design-16;
}
body {
@@ -48,10 +138,36 @@
.common-neon-inset {
border: 1px solid rgba(128, 223, 231, 0.65);
border-radius: 5px;
padding: 8px 10px;
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
}
.common-neon-inset-glow {
border: 1px solid rgba(128, 223, 231, 0.65);
border-radius: 5px;
padding: calc(var(--design-unit) * 8) calc(var(--design-unit) * 10);
background:
linear-gradient(180deg, rgba(18, 44, 58, 0.92), rgba(8, 22, 34, 0.9)),
radial-gradient(circle at top, rgba(128, 223, 231, 0.16), transparent 62%);
box-shadow:
inset 0 0 calc(var(--design-unit) * 8) rgba(128, 223, 231, 0.72),
inset 0 0 calc(var(--design-unit) * 18) rgba(128, 223, 231, 0.18),
0 0 calc(var(--design-unit) * 6) rgba(128, 223, 231, 0.35),
0 0 calc(var(--design-unit) * 18) rgba(128, 223, 231, 0.24);
position: relative;
overflow: hidden;
}
.common-neon-inset-glow::before {
content: "";
position: absolute;
inset: 1px;
border-radius: 4px;
border-top: 1px solid rgba(214, 249, 252, 0.8);
opacity: 0.85;
pointer-events: none;
}
.game-panel {
border: 1px solid rgba(124, 232, 255, 0.12);
background: linear-gradient(
@@ -74,4 +190,41 @@
0 0 0 1px rgba(245, 200, 107, 0.2),
0 18px 40px rgba(245, 200, 107, 0.14);
}
.desktop-game-history-glow {
filter: drop-shadow(
0 0 calc(var(--design-unit) * 4) rgba(49, 208, 255, 0.65)
)
drop-shadow(0 0 calc(var(--design-unit) * 14) rgba(49, 208, 255, 0.28));
}
.desktop-control-chip {
margin-left: calc(var(--design-unit) * -4);
margin-right: calc(var(--design-unit) * -18);
}
.desktop-control-total {
margin-left: calc(var(--design-unit) * -26);
margin-right: calc(var(--design-unit) * -26);
}
.desktop-control-actions {
margin-left: calc(var(--design-unit) * -18);
margin-right: 0;
}
.desktop-control-confirm {
margin-left: calc(var(--design-unit) * -10);
}
.history-scroll-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
.history-scroll-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
}