邹方成 54ce24b7b8 feat(游戏): 添加对对碰游戏相关接口
添加开始游戏、执行配对、获取游戏状态和获取卡牌配置的接口
2025-12-19 09:17:43 +08:00

1051 lines
42 KiB
Vue
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.

<template>
<view class="page-container">
<!-- Animated Branded Background -->
<view class="bg-layer">
<view class="game-glow glow-1"></view>
<view class="game-glow glow-2"></view>
<view class="game-glow glow-3"></view>
</view>
<!-- Top Navigation & Ticker -->
<view class="top-nav">
<view class="win-ticker" v-if="winRecords.length > 0">
<text class="ticker-icon">📣</text>
<swiper vertical autoplay circular interval="3000" class="ticker-swiper">
<swiper-item v-for="rec in winRecords" :key="rec.id">
<view class="ticker-item">
恭喜 <text class="ticker-user">玩家</text> 抽中 <text class="ticker-prize">{{ rec.title }}</text>
</view>
</swiper-item>
</swiper>
</view>
<view class="rules-btn" @click="showRules = true">
<text class="rules-icon">?</text>
玩法说明
</view>
</view>
<scroll-view class="page-content" scroll-y :enhanced="true" :show-scrollbar="false">
<!-- Loading State -->
<view class="loading-portal" v-if="loading && !issues.length">
<view class="loading-animation"></view>
<text class="loading-text">正在连接游戏大厅...</text>
</view>
<!-- Empty State -->
<view class="empty-portal animate-fade-in" v-else-if="!loading && issues.length === 0">
<view class="empty-art-wrapper">
<view class="empty-orb"></view>
<text class="empty-emoji">🎮</text>
</view>
<text class="empty-title">当前无活动上线</text>
<text class="empty-desc">别担心,去仓库看看其他好货吧</text>
<button class="return-home-btn" @click="goHome">返回大厅</button>
</view>
<block v-else>
<!-- Banner -->
<view class="banner-section animate-slide-up" v-if="detail.banner" @click="onPreviewBanner">
<image class="banner-image" :src="detail.banner" mode="widthFix" />
<view class="banner-overlay"></view>
</view>
<!-- Dynamic Game Arena -->
<block v-if="gameId">
<!-- Stats Header -->
<view class="arena-stats">
<view class="stat-gem animate-bounce-in">
<view class="gem-icon">💎</view>
<view class="gem-content">
<text class="gem-label">消除得分</text>
<text class="gem-value">{{ totalPairs }}</text>
</view>
</view>
<view class="stat-gem animate-bounce-in" style="animation-delay: 0.1s">
<view class="gem-icon">📦</view>
<view class="gem-content">
<text class="gem-label">牌堆容量</text>
<text class="gem-value">{{ deckCount }}</text>
</view>
</view>
<view class="stat-gem animate-bounce-in" style="animation-delay: 0.2s">
<view class="gem-icon">⏳</view>
<view class="gem-content">
<text class="gem-label">当前轮次</text>
<text class="gem-value">{{ currentRound }}</text>
</view>
</view>
</view>
<!-- Apple Effect Overlay -->
<view class="special-overlay" v-if="showAppleEffect">
<view class="apple-card animate-zoom-in">
<view class="apple-glow"></view>
<text class="apple-emoji">🍎</text>
<text class="apple-title">苹果加成!</text>
<text class="apple-subtitle">检测到运气爆发,额外补牌中...</text>
</view>
</view>
<!-- The Game Board -->
<view class="board-arena glass-panel animate-zoom-in">
<view class="board-grid">
<view v-for="(slot, idx) in board" :key="idx"
class="card-unit"
:class="{
'is-pairing': slot && pairingIds.includes(slot.id),
'is-disappearing': slot && disappearingIds.includes(slot.id),
'is-drawing': slot && newCardIds.includes(slot.id),
'is-rare': slot && slot.type === 'A'
}">
<view v-if="slot" class="card-face">
<view class="card-shine" v-if="slot.type === 'A'"></view>
<view class="match-badge" v-if="pairingIds.includes(slot.id)">MATCH!</view>
<image v-if="slot.image" class="card-thumb" :src="slot.image" mode="aspectFit" />
<text class="card-tag" v-else>{{ slot.name || slot.type }}</text>
</view>
<view v-else class="card-void">
<view class="void-circle"></view>
</view>
</view>
</view>
</view>
</block>
<!-- Issue Selection (Hall State) -->
<view class="entry-hall glass-panel animate-slide-up" v-else>
<view class="arena-header">
<view class="hall-title">
<text class="title-main">选择竞技场</text>
<text class="title-sub">Select Your Arena</text>
</view>
<view class="issue-count">活跃中 {{ issues.length }}</view>
</view>
<!-- Horizontal Card Selector -->
<scroll-view scroll-x class="issue-scroll" :show-scrollbar="false">
<view class="issue-cards">
<view v-for="(it, idx) in issues" :key="it.id"
class="issue-card"
:class="{ active: selectedIssueIndex === idx }"
@click="onSelectIssue(idx)">
<view class="card-background-glow"></view>
<text class="issue-no">NO.{{ it.no || (idx + 1) }}</text>
<text class="issue-name">{{ it.title }}</text>
<view class="issue-tag" :class="'status-' + it.status">{{ it.status_text }}</view>
<view class="active-indicator" v-if="selectedIssueIndex === idx">
<view class="dot"></view>
SELECTED
</view>
</view>
</view>
</scroll-view>
<!-- Info Tabs -->
<view class="hall-tabs">
<view class="hall-tab" :class="{ active: tabActive === 'pool' }" @click="tabActive = 'pool'">
奖励预览
<view class="tab-bar-glow" v-if="tabActive === 'pool'"></view>
</view>
<view class="hall-tab" :class="{ active: tabActive === 'records' }" @click="tabActive = 'records'">
中奖动态
<view class="tab-bar-glow" v-if="tabActive === 'records'"></view>
</view>
</view>
<!-- Content Lists -->
<view class="tab-container">
<!-- Pool Preview -->
<view v-if="tabActive === 'pool'" class="rewards-mosaic">
<view v-for="(rw, idx) in (rewardsMap[currentIssueId] || [])" :key="rw.id" class="mosaic-item animate-stagger" :style="{ '--delay': idx * 0.05 + 's' }">
<view class="mosaic-inner">
<view class="mosaic-img-box">
<image :src="rw.image" class="mosaic-img" mode="aspectFill" />
<view class="mosaic-boss" v-if="rw.boss">BOSS</view>
<view class="mosaic-prob">{{ rw.percent }}%</view>
</view>
<text class="mosaic-title">{{ rw.title }}</text>
</view>
</view>
</view>
<!-- Win Records -->
<view v-if="tabActive === 'records'" class="dynamic-records">
<view v-for="(it, idx) in winRecords" :key="it.id" class="win-row animate-stagger" :style="{ '--delay': idx * 0.05 + 's' }">
<view class="win-avatar-box">
<image class="win-avatar" src="/static/images/common/default-avatar.png" mode="aspectFill" />
</view>
<view class="win-info">
<view class="win-top">
<text class="win-user">玩家</text>
<text class="win-time">刚刚</text>
</view>
<text class="win-item-name">获得 {{ it.title }} x{{ it.count }}</text>
</view>
<image class="win-thumb" :src="it.image" mode="aspectFill" />
</view>
<view class="empty-win" v-if="!winRecords.length">
<text class="empty-win-icon">🏜️</text>
<text class="empty-win-text">虚位以待,快来挑战</text>
</view>
</view>
</view>
</view>
</block>
<!-- Safe Bottom Padding -->
<view style="height: 140rpx"></view>
</scroll-view>
<!-- Floating Action Hub -->
<view class="action-hub" v-if="activityId && issues.length">
<view class="action-glow"></view>
<!-- Entry Mode -->
<button v-if="!gameId" class="hub-btn neon-btn pulsate" @click="handleStartGame" :loading="loading">
<text class="btn-text">立即进入竞技场</text>
<view class="btn-energy-line"></view>
</button>
<!-- Gaming Mode -->
<view v-else-if="!gameOver" class="gaming-controls">
<view class="auto-toggle-box" @click="isAutoMode = !isAutoMode">
<view class="toggle-track" :class="{ active: isAutoMode }">
<view class="toggle-thumb"></view>
</view>
<text class="toggle-label">{{ isAutoMode ? '自动模式 ON' : '手动模式 OFF' }}</text>
</view>
<button class="hub-btn primary-btn" @click="handlePlayRound" :loading="loading" :disabled="isAnimating">
<text v-if="isAnimating" class="matching-pulse">🧠 {{ isAutoMode ? '自动匹配中...' : '计算配对中...' }}</text>
<text v-else>执行下一轮消除</text>
</button>
</view>
<view class="game-over-hub" v-else>
<button class="hub-btn secondary-btn" @click="resetGame">退出游戏</button>
<button class="hub-btn gold-btn" @click="resetGame">再次发起挑战</button>
</view>
</view>
<!-- Rule Modal -->
<view class="modal-layer" v-if="showRules" @tap="showRules = false">
<view class="premium-modal animate-bounce-in" @tap.stop>
<view class="modal-header">
<text class="modal-title">匹配挑战规则</text>
<view class="close-x" @tap="showRules = false">×</view>
</view>
<scroll-view class="modal-scroll" scroll-y>
<view class="rule-item">
<text class="rule-no">01</text>
<view class="rule-content">每局游戏开始时将从牌堆中抽取卡牌填充至 3x3 棋盘</view>
</view>
<view class="rule-item">
<text class="rule-no">02</text>
<view class="rule-content">当棋盘中出现 3 张相同卡牌时将自动进行对对碰消除并获得奖励</view>
</view>
<view class="rule-item">
<text class="rule-no">03</text>
<view class="rule-content">🍎 如果初始手牌中包含苹果卡将触发暴击效果额外抽取更多手牌</view>
</view>
<view class="rule-item">
<text class="rule-no">04</text>
<view class="rule-content">消除后会自动补位直到牌堆耗尽或无法继续消除游戏结束</view>
</view>
</scroll-view>
<button class="modal-confirm" @tap="showRules = false">我明白了</button>
</view>
</view>
<!-- Result Modal -->
<view class="modal-layer" v-if="showResult" @tap="showResult = false">
<view class="settlement-card glass-panel animate-zoom-in" @tap.stop>
<view class="settlement-crown">👑</view>
<text class="settlement-title">挑战圆满结束</text>
<view class="settlement-stats">
<view class="set-stat">
<text class="set-val">{{ totalPairs }}</text>
<text class="set-lab">消除对数</text>
</view>
</view>
<text class="settlement-tip">恭喜所有获得奖励已存入您的账户库</text>
<button class="set-btn" @click="resetGame">领取战利品</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
getActivityDetail,
getActivityIssues,
getActivityIssueRewards,
getActivityWinRecords,
startMatchingGame,
playMatchingGame,
getMatchingGameState,
getMatchingCardTypes
} from '../../../api/appUser'
// Activity State
const activityId = ref('')
const detail = ref({})
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const tabActive = ref('pool')
const winRecords = ref([])
const loading = ref(true) // Start as true to prevent empty-state flash
const hasInited = ref(false)
const cardMetaMap = ref({})
// Game State
const gameId = ref('')
const deckCount = ref(0)
const totalPairs = ref(0)
const currentRound = ref(0)
const gameOver = ref(false)
const isAnimating = ref(false)
const pairingIds = ref([])
const disappearingIds = ref([])
const newCardIds = ref([])
const isAutoMode = ref(false)
const showResult = ref(false)
const showAppleEffect = ref(false)
const showRules = ref(false)
const board = ref(Array(9).fill(null)) // Use a persistent array of 9 for the grid
// Activity Logic
async function fetchDetail(id) {
try {
const data = await getActivityDetail(id)
detail.value = data || {}
} catch (e) {}
}
async function fetchIssues(id) {
loading.value = true
try {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
} catch (e) {
console.error('fetchIssues error:', e)
} finally {
loading.value = false
}
}
function unwrap(list) {
if (Array.isArray(list)) return list
const obj = list || {}
const data = obj.data || {}
// Deep search for array
const arr = obj.issues || obj.list || obj.items || data.issues || data.list || data.items || data
return Array.isArray(arr) ? arr : []
}
function normalizeIssues(list) {
console.log('[DEBUG] Raw Issues Data:', list)
const arr = unwrap(list)
console.log('[DEBUG] Unwrapped Issues:', arr)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? i.issue_no ?? `${idx+1}`,
no: i.no ?? i.index ?? i.issue_no ?? null,
status: i.status,
status_text: i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : '已结束'
}))
}
async function fetchRewardsForIssues(aid) {
if (!currentIssueId.value) return
try {
const data = await getActivityIssueRewards(aid, currentIssueId.value)
const arr = Array.isArray(data) ? data : (data?.data || [])
rewardsMap.value[currentIssueId.value] = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: i.product_image ?? i.image ?? '',
weight: Number(i.weight) || 0,
boss: !!(i.is_boss || i.boss),
percent: 0 // logic to calculate percentage if total_weight is known
}))
} catch (e) {}
}
async function fetchWinRecords(aid) {
try {
const data = await getActivityWinRecords(aid, 1, 20)
const arr = Array.isArray(data) ? data : (data?.data || [])
winRecords.value = arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
image: i.image ?? '',
count: i.count ?? 1,
percent: i.percent
}))
} catch (e) {}
}
function pickLatestIssueId(list) {
return list[list.length - 1]?.id || ''
}
function setSelectedById(id) {
const idx = issues.value.findIndex(x => x.id === id)
selectedIssueIndex.value = Math.max(0, idx)
currentIssueId.value = issues.value[selectedIssueIndex.value]?.id || ''
}
function onIssueChange(e) {
const idx = e.detail.value[0]
onSelectIssue(idx)
}
function onSelectIssue(idx) {
if (isAnimating.value) return
selectedIssueIndex.value = idx
currentIssueId.value = issues.value[idx]?.id || ''
fetchRewardsForIssues(activityId.value)
}
async function handleStartGame() {
if (!currentIssueId.value) return
loading.value = true
try {
const res = await startMatchingGame(issues.value[selectedIssueIndex.value].id)
gameId.value = res.game_id
deckCount.value = res.deck_count
totalPairs.value = 0
currentRound.value = 1
// Spec priority: res.board (9-slot) > res.hand (old list)
board.value = normalizeCards(res.board || res.hand)
// If we fell back to 'hand' (old API), we might need to pad it to 9 slots manually
// to match the new 9-slot grid expectation in the template
if (!res.board && board.value.length < 9) {
while(board.value.length < 9) board.value.push(null)
}
// Reset loop
isAutoMode.value = false
} catch (e) {
uni.showToast({ title: '初始化失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function handlePlayRound() {
if (!gameId.value || isAnimating.value) return
loading.value = !isAutoMode.value // Don't show full-page loading in auto mode to avoid flicker
try {
const res = await playMatchingGame(gameId.value)
totalPairs.value = res.total_pairs
gameOver.value = res.game_over
deckCount.value = (res.round?.deck_count !== undefined) ? res.round.deck_count : (res.deck_count || deckCount.value)
await processRound(res.round)
if (res.game_over) {
uni.removeStorageSync('matching_game_id')
// Small pause after all animations to see the final board
setTimeout(() => { showResult.value = true }, 1500)
} else if (isAutoMode.value) {
// Auto-loop
setTimeout(() => {
handlePlayRound()
}, 500)
}
} catch (err) {
isAutoMode.value = false // Stop auto mode on error
uni.showToast({ title: '执行失败', icon: 'none' })
} finally {
loading.value = false
}
}
// Game Logic
async function initGame(aid) {
try {
loading.value = true
// Parallel fetch basic info + card metadata
const [detailRes, issuesRes, cardTypesRes] = await Promise.all([
getActivityDetail(aid),
getActivityIssues(aid),
getMatchingCardTypes()
])
detail.value = detailRes || {}
issues.value = normalizeIssues(issuesRes)
// Build card metadata map
const types = Array.isArray(cardTypesRes) ? cardTypesRes : (cardTypesRes?.list || cardTypesRes?.data || [])
const meta = {}
types.forEach(t => {
meta[t.code] = {
name: t.name,
image: t.image_url || t.image
}
})
cardMetaMap.value = meta
console.log('[DEBUG] Card Meta Map initialized:', Object.keys(meta).length)
if (issues.value.length > 0) {
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(aid)
// Auto-start or Restore
const restored = await checkGameStatus()
if (!restored && currentIssueId.value) {
await handleStartGame()
}
}
// Non-blocking fetch records
fetchWinRecords(aid)
} catch (err) {
console.error('[DEBUG] Init failed:', err)
} finally {
hasInited.value = true
loading.value = false
}
}
function normalizeCards(list) {
const arr = Array.isArray(list) ? list : []
return arr.map((item, idx) => {
// Pass through nulls (for empty slots in 9-grid)
if (item === null) return null
const code = typeof item === 'string' ? item : (item.type ?? item.card_type ?? item.cardType ?? item.code ?? item.name ?? item.title ?? '')
const meta = cardMetaMap.value[code] || {}
if (typeof item === 'string') {
return {
id: `card-${idx}-${Date.now()}`,
type: code,
image: meta.image || '',
name: meta.name || code
}
}
const normalized = {
id: item.id ?? item.card_id ?? `card-${idx}-${Date.now()}`,
type: code,
image: item.image ?? item.product_image ?? item.image_url ?? item.thumb ?? item.img ?? meta.image ?? '',
name: item.name ?? item.title ?? meta.name ?? code
}
return normalized
})
}
async function processRound(round, isFirstRound = false) {
if (!round) return
isAnimating.value = true
currentRound.value = round.round
// 1. Precise Elimination based on `card_ids` in pairs
// Spec: round.pairs contains { card_ids: ["c1", "c5"] }
if (round.pairs && round.pairs.length > 0) {
console.log('[DEBUG] Processing pairs:', round.pairs)
const toPairIds = []
round.pairs.forEach(p => {
if (p.card_ids && Array.isArray(p.card_ids)) {
toPairIds.push(...p.card_ids)
} else {
// Fallback for old API (count based logic)
const type = p.card_type ?? p.type
let count = p.count || 2
// Naive fallback: find first N cards of this type
// This is risky but keeps old logic working if partial update
board.value.forEach(c => {
if (c && c.type === type && count > 0 && !toPairIds.includes(c.id)) {
toPairIds.push(c.id)
count--
}
})
}
})
if (toPairIds.length > 0) {
pairingIds.value = toPairIds
await sleep(1200)
disappearingIds.value = [...toPairIds]
pairingIds.value = []
await sleep(600)
// Clear the slots for these exact IDs
const nextBoard = [...board.value]
toPairIds.forEach(id => {
const idx = nextBoard.findIndex(s => s && s.id === id)
if (idx !== -1) nextBoard[idx] = null
})
board.value = nextBoard
disappearingIds.value = []
}
}
// 2. Stable Filling: Sync with Final Board
// Spec: round.board is the final state.
// We compare final board with current board to find what changed (Filling empties).
if (round.board) {
const finalBoard = normalizeCards(round.board)
const currentBoard = [...board.value]
const addedIds = []
// Apply changes (fill nulls)
for (let i = 0; i < 9; i++) {
// If local is null but final is not null -> New Card filled here
if (currentBoard[i] === null && finalBoard[i] !== null) {
currentBoard[i] = finalBoard[i]
addedIds.push(finalBoard[i].id)
}
// Sync override (just in case)
else if (finalBoard[i] !== null && currentBoard[i]?.id !== finalBoard[i].id) {
currentBoard[i] = finalBoard[i]
// Maybe animate this too if it's a swap? Spec says stable, so mostly filling.
}
}
board.value = currentBoard
if (addedIds.length > 0) {
newCardIds.value = addedIds
await sleep(1000)
newCardIds.value = []
}
}
// Deck count update if available
// (Usually in root response, but if in round object...)
// if (round.deck_count !== undefined) deckCount.value = round.deck_count
isAnimating.value = false
}
// Reconnection logic
async function checkGameStatus() {
const savedGameId = uni.getStorageSync('matching_game_id')
if (!savedGameId) return false
loading.value = true
try {
const res = await getMatchingGameState(savedGameId)
gameId.value = savedGameId
// Spec: res.board is the fixed 9-slot array directly from storage including nulls
board.value = normalizeCards(res.board)
// Ensure it's 9 slots just in case
while (board.value.length < 9) {
board.value.push(null)
}
totalPairs.value = res.total_pairs
currentRound.value = res.round
deckCount.value = res.deck_count
uni.showToast({ title: '已恢复游戏进度', icon: 'none' })
return true
} catch (e) {
uni.removeStorageSync('matching_game_id')
return false
} finally {
loading.value = false
}
}
function resetGame() {
uni.removeStorageSync('matching_game_id')
gameId.value = ''
board.value = Array(9).fill(null)
totalPairs.value = 0
currentRound.value = 0
gameOver.value = false
showResult.value = false
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function onPreviewBanner() {
const url = detail.value.banner
if (url) uni.previewImage({ urls: [url] })
}
onLoad((opts) => {
const id = opts.id || ''
if (id) {
activityId.value = id
initGame(id)
} else {
uni.showToast({ title: '参数错误', icon: 'none' })
loading.value = false
}
})
function goHome() {
uni.switchTab({ url: '/pages/shop/index' })
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: #0f0c29;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Branded Background */
.bg-layer {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
z-index: 0;
}
.game-glow {
position: absolute;
border-radius: 50%;
filter: blur(120rpx);
opacity: 0.6;
}
.glow-1 { width: 600rpx; height: 600rpx; background: radial-gradient(circle, #ff6b35 0%, transparent 70%); top: -150rpx; left: -100rpx; }
.glow-2 { width: 700rpx; height: 700rpx; background: radial-gradient(circle, #e81cff 0%, transparent 70%); bottom: -200rpx; right: -100rpx; }
.glow-3 { width: 500rpx; height: 500rpx; background: radial-gradient(circle, #fcb045 0%, transparent 70%); top: 35%; right: -150rpx; }
/* Top Nav & Ticker */
.top-nav {
position: relative;
z-index: 10;
padding: $spacing-md $spacing-lg;
padding-top: calc($spacing-md + env(safe-area-inset-top));
display: flex;
align-items: center;
justify-content: space-between;
}
.win-ticker {
flex: 1;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
height: 64rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
padding: 0 $spacing-md;
margin-right: $spacing-md;
border: 1rpx solid rgba(255, 255, 255, 0.15);
}
.ticker-icon { font-size: 24rpx; margin-right: 12rpx; }
.ticker-swiper { flex: 1; height: 100%; }
.ticker-item {
height: 100%;
display: flex;
align-items: center;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
}
.ticker-prize { color: #ffca28; font-weight: bold; margin-left: 8rpx; }
.rules-btn {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
padding: 12rpx $spacing-md;
border-radius: $radius-round;
color: #fff;
font-size: 22rpx;
display: flex;
align-items: center;
border: 1rpx solid rgba(255, 255, 255, 0.2);
}
.rules-icon {
width: 28rpx; height: 28rpx; background: rgba(255,255,255,0.2); border-radius: 50%;
display: flex; align-items: center; justify-content: center; margin-right: 8rpx;
}
.page-content {
flex: 1;
position: relative;
z-index: 1;
}
/* Glass Panels */
.glass-panel {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(25px);
border: 1rpx solid rgba(255, 255, 255, 0.12);
border-radius: 40rpx;
box-shadow: 0 20rpx 50rpx rgba(0,0,0,0.3);
}
/* Loading Portal */
.loading-portal {
padding-top: 200rpx;
display: flex; flex-direction: column; align-items: center;
}
.loading-animation {
width: 120rpx; height: 120rpx;
border: 4rpx solid rgba(255,255,255,0.1);
border-top-color: #ff6b35;
border-radius: 50%;
animation: rotate 1s linear infinite;
margin-bottom: $spacing-xl;
}
.loading-text { color: rgba(255,255,255,0.6); font-size: $font-sm; letter-spacing: 2rpx; }
/* Empty States */
.empty-portal {
padding-top: 150rpx;
display: flex; flex-direction: column; align-items: center;
}
.empty-art-wrapper {
position: relative; width: 240rpx; height: 240rpx; margin-bottom: 60rpx;
}
.empty-orb {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(circle, rgba(232, 28, 255, 0.2) 0%, transparent 70%);
animation: pulse 3s infinite;
}
.empty-emoji { font-size: 140rpx; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); filter: drop-shadow(0 0 20rpx #e81cff); }
.empty-title { font-size: 40rpx; color: #fff; font-weight: 800; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.5); margin-bottom: 80rpx; }
.return-home-btn {
width: 320rpx; height: 90rpx; background: linear-gradient(90deg, #FF6B35, #ff9f43);
color: #fff; border-radius: 45rpx; font-weight: bold; border: none;
}
/* Banner */
.banner-section { margin: $spacing-md $spacing-lg $spacing-xl; border-radius: 32rpx; overflow: hidden; position: relative; }
.banner-image { width: 100%; display: block; }
.banner-overlay { position: absolute; bottom: 0; left: 0; width: 100%; height: 60%; background: linear-gradient(to top, rgba(15,12,41,0.8), transparent); }
/* Arena Stats */
.arena-stats {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; padding: 0 $spacing-lg; margin-bottom: 40rpx;
}
.stat-gem {
background: rgba(255, 255, 255, 0.05);
border: 1rpx solid rgba(255,255,255,0.1);
border-radius: 30rpx;
padding: 24rpx 16rpx;
display: flex; flex-direction: column; align-items: center;
backdrop-filter: blur(10px);
}
.gem-icon { font-size: 36rpx; margin-bottom: 8rpx; }
.gem-label { font-size: 18rpx; color: rgba(255,255,255,0.4); text-transform: uppercase; margin-bottom: 4rpx; }
.gem-value { font-size: 34rpx; font-weight: 900; color: #fff; font-family: 'DIN Alternate', sans-serif; text-shadow: 0 0 10rpx rgba(232, 28, 255, 0.5); }
/* Apple Effect */
.special-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.6); backdrop-filter: blur(5px); }
.apple-card { width: 500rpx; height: 600rpx; background: #fff; border-radius: 50rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.apple-glow { position: absolute; width: 150%; height: 150%; background: radial-gradient(circle, rgba(255, 107, 53, 0.3) 0%, transparent 70%); animation: rotate 4s linear infinite; }
.apple-emoji { font-size: 160rpx; z-index: 1; margin-bottom: 40rpx; }
.apple-title { font-size: 44rpx; font-weight: 900; color: #f44336; z-index: 1; }
.apple-subtitle { font-size: 24rpx; color: #777; z-index: 1; margin-top: 10rpx; }
/* Board Arena */
.board-arena { margin: 0 $spacing-lg; padding: 30rpx; }
.board-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; }
.card-unit {
aspect-ratio: 1; background: rgba(0, 0, 0, 0.2); border-radius: 24rpx; position: relative;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
&.is-pairing { transform: scale(1.1); box-shadow: 0 0 50rpx rgba(255, 107, 53, 0.8); z-index: 5; }
&.is-disappearing { transform: scale(0); opacity: 0; filter: blur(10px); }
&.is-drawing { animation: cardDraw 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) backwards; }
&.is-rare { border: 2rpx solid #ffca28; box-shadow: inset 0 0 20rpx rgba(255, 202, 40, 0.2); }
}
.match-badge {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-15deg);
background: #ff6b35; color: #fff; font-size: 24rpx; font-weight: 900;
padding: 4rpx 12rpx; border-radius: 8rpx; z-index: 10;
animation: matchPop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
@keyframes matchPop { from { transform: translate(-50%, -50%) scale(0) rotate(0deg); } to { transform: translate(-50%, -50%) scale(1) rotate(-15deg); } }
.card-face {
width: 100%; height: 100%; border-radius: 24rpx; background: #fff; padding: 12rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow: hidden;
}
.card-shine { position: absolute; top: -100%; left: -100%; width: 200%; height: 200%; background: linear-gradient(135deg, transparent 45%, rgba(255,255,255,0.8) 50%, transparent 55%); animation: shine 3s infinite; }
.card-thumb { width: 80%; height: 80%; border-radius: 12rpx; }
.card-tag { font-size: 18rpx; color: #333; font-weight: 800; margin-top: 8rpx; @include text-ellipsis(1); }
.card-void { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; opacity: 0.3; }
.void-circle { width: 40rpx; height: 40rpx; border: 4rpx dashed rgba(255,255,255,0.2); border-radius: 50%; }
/* Entry Hall */
.entry-hall { margin: 0 $spacing-lg; padding: 40rpx 0; }
.arena-header { padding: 0 40rpx; display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 40rpx; }
.hall-title { display: flex; flex-direction: column; }
.title-main { font-size: 38rpx; font-weight: 900; color: #fff; letter-spacing: 2rpx; }
.title-sub { font-size: 18rpx; color: rgba(255,255,255,0.3); font-weight: bold; }
.issue-count { font-size: 20rpx; background: rgba(255,255,255,0.1); color: #ff6b35; padding: 8rpx 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(255, 255, 255, 0.1); }
.issue-scroll { width: 100%; margin-bottom: 40rpx; }
.issue-cards { display: flex; padding: 20rpx 40rpx; gap: 30rpx; }
.issue-card {
flex-shrink: 0; width: 260rpx; height: 320rpx; border-radius: 36rpx; background: rgba(255,255,255,0.05); border: 1rpx solid rgba(255,255,255,0.1);
padding: 30rpx; display: flex; flex-direction: column; position: relative; overflow: hidden;
transition: all 0.3s;
&.active { background: rgba(255,255,255,0.12); border-color: #ff6b35; transform: translateY(-10rpx); box-shadow: 0 20rpx 40rpx rgba(255, 107, 53, 0.2); }
}
.card-background-glow { position: absolute; top: -50rpx; right: -50rpx; width: 150rpx; height: 150rpx; background: radial-gradient(circle, rgba(232, 28, 255, 0.1) 0%, transparent 70%); }
.issue-no { font-size: 20rpx; color: rgba(255,255,255,0.3); font-weight: 900; margin-bottom: 20rpx; }
.issue-name { font-size: 32rpx; color: #fff; font-weight: bold; line-height: 1.4; flex: 1; }
.issue-tag { font-size: 18rpx; padding: 6rpx 16rpx; border-radius: 10rpx; align-self: flex-start; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); }
.issue-tag.status-1 { background: rgba(76, 175, 80, 0.2); color: #81c784; }
.active-indicator { position: absolute; bottom: 24rpx; right: 24rpx; font-size: 16rpx; color: #ff6b35; font-weight: 900; display: flex; align-items: center; gap: 6rpx; }
.active-indicator .dot { width: 8rpx; height: 8rpx; background: #ff6b35; border-radius: 50%; animation: pulse 1s infinite; }
.hall-tabs { display: flex; padding: 0 40rpx; gap: 60rpx; margin-bottom: 30rpx; }
.hall-tab {
font-size: 28rpx; color: rgba(255,255,255,0.4); font-weight: bold; position: relative; padding-bottom: 15rpx;
&.active { color: #fff; }
}
.tab-bar-glow { position: absolute; bottom: 0; left: 0; width: 100%; height: 4rpx; background: #ff6b35; border-radius: 2rpx; box-shadow: 0 0 10rpx #ff6b35; }
/* Rewards Mosaic */
.rewards-mosaic { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20rpx; padding: 0 30rpx; }
.mosaic-item { background: rgba(255, 255, 255, 0.03); border-radius: 24rpx; padding: 16rpx; }
.mosaic-inner { display: flex; flex-direction: column; gap: 12rpx; }
.mosaic-img-box { width: 100%; aspect-ratio: 1; background: rgba(0,0,0,0.2); border-radius: 16rpx; position: relative; overflow: hidden; }
.mosaic-img { width: 100%; height: 100%; }
.mosaic-boss { position: absolute; top: 12rpx; right: 12rpx; font-size: 16rpx; background: #ffca28; color: #000; font-weight: 900; padding: 4rpx 10rpx; border-radius: 6rpx; }
.mosaic-prob { position: absolute; bottom: 12rpx; left: 12rpx; font-size: 14rpx; background: rgba(0,0,0,0.6); color: #fff; padding: 4rpx 12rpx; border-radius: 6rpx; backdrop-filter: blur(4rpx); }
.mosaic-title { font-size: 22rpx; color: rgba(255,255,255,0.8); font-weight: bold; @include text-ellipsis(1); }
/* Dynamic Records */
.dynamic-records { display: flex; flex-direction: column; gap: 20rpx; padding: 0 30rpx; }
.win-row { display: flex; align-items: center; background: rgba(255,255,255,0.03); padding: 20rpx; border-radius: 24rpx; }
.win-avatar-box { width: 70rpx; height: 70rpx; border-radius: 50%; border: 2rpx solid rgba(255,255,255,0.1); overflow: hidden; margin-right: 20rpx; }
.win-avatar { width: 100%; height: 100%; }
.win-info { flex: 1; display: flex; flex-direction: column; gap: 4rpx; }
.win-top { display: flex; align-items: baseline; gap: 12rpx; }
.win-user { font-size: 22rpx; color: #fff; font-weight: bold; }
.win-time { font-size: 18rpx; color: rgba(255,255,255,0.3); }
.win-item-name { font-size: 20rpx; color: #ff6b35; font-weight: bold; }
.win-thumb { width: 80rpx; height: 80rpx; border-radius: 12rpx; }
/* Action Hub */
.action-hub {
position: fixed; bottom: 0; left: 0; width: 100%; z-index: 100;
padding: 40rpx; padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
background: linear-gradient(to top, #0f0c29 70%, transparent);
}
.action-glow { position: absolute; bottom: 0; left: 10%; width: 80%; height: 100rpx; background: rgba(255, 107, 53, 0.3); filter: blur(40rpx); opacity: 0.6; }
.gaming-controls {
display: flex; flex-direction: column; gap: 30rpx; width: 100%;
}
.auto-toggle-box {
align-self: flex-center; display: flex; align-items: center; gap: 20rpx;
background: rgba(255, 255, 255, 0.05); padding: 12rpx 30rpx; border-radius: 40rpx;
border: 1rpx solid rgba(255,255,255,0.1);
}
.toggle-track {
width: 80rpx; height: 40rpx; background: rgba(255,255,255,0.1); border-radius: 20rpx;
position: relative; transition: all 0.3s;
&.active { background: #4caf50; .toggle-thumb { transform: translateX(40rpx); } }
}
.toggle-thumb {
position: absolute; top: 4rpx; left: 4rpx; width: 32rpx; height: 32rpx;
background: #fff; border-radius: 50%; transition: all 0.3s;
box-shadow: 0 0 10rpx rgba(0,0,0,0.3);
}
.toggle-label { font-size: 22rpx; color: #fff; font-weight: 800; }
/* Action Hub Standard Buttons */
.hub-btn {
width: 100%; height: 110rpx; border-radius: 55rpx; display: flex; align-items: center; justify-content: center;
font-weight: 900; font-size: 32rpx; letter-spacing: 2rpx; position: relative; overflow: hidden; transition: all 0.2s;
&:active { transform: scale(0.96); }
}
.neon-btn {
background: linear-gradient(90deg, #FF6B35, #E81CFF);
color: #fff; box-shadow: 0 15rpx 40rpx rgba(232, 28, 255, 0.4);
}
.btn-energy-line { position: absolute; bottom: 0; left: 0; width: 100%; height: 6rpx; background: rgba(255,255,255,0.3); animation: energyFlow 2s linear infinite; }
.primary-btn { background: #fff; color: #000; box-shadow: 0 10rpx 30rpx rgba(255,255,255,0.2); }
.secondary-btn { background: rgba(255,255,255,0.1); color: #fff; }
.gold-btn { background: $gradient-gold; color: #000; }
.game-over-hub { display: flex; gap: 20rpx; .hub-btn { flex: 1; } }
/* Modals */
.modal-layer { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; background: rgba(0,0,0,0.85); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; }
.premium-modal { width: 620rpx; max-height: 80vh; background: #1a1a2e; border: 1rpx solid rgba(255,255,255,0.1); border-radius: 50rpx; padding: 50rpx; display: flex; flex-direction: column; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40rpx; }
.modal-title { font-size: 36rpx; color: #fff; font-weight: 900; }
.close-x { font-size: 40rpx; color: rgba(255,255,255,0.3); line-height: 1; }
.modal-scroll { flex: 1; overflow: hidden; }
.rule-item { display: flex; gap: 30rpx; margin-bottom: 40rpx; }
.rule-no { font-size: 32rpx; font-weight: 900; color: #ff6b35; font-family: 'DIN Alternate'; }
.rule-content { font-size: 26rpx; color: rgba(255,255,255,0.7); line-height: 1.6; }
.modal-confirm { width: 100%; height: 90rpx; border-radius: 45rpx; background: #fff; color: #000; font-weight: 900; margin-top: 40rpx; }
/* Settlement Card */
.settlement-card { width: 600rpx; padding: 80rpx 50rpx; display: flex; flex-direction: column; align-items: center; text-align: center; }
.settlement-crown { font-size: 120rpx; margin-bottom: 40rpx; filter: drop-shadow(0 0 20rpx #ffea00); }
.settlement-title { font-size: 44rpx; color: #fff; font-weight: 900; margin-bottom: 60rpx; }
.settlement-stats { margin-bottom: 60rpx; }
.set-stat { display: flex; flex-direction: column; align-items: center; }
.set-val { font-size: 100rpx; color: #ff6b35; font-weight: 900; font-family: 'DIN Alternate'; line-height: 1; }
.set-lab { font-size: 22rpx; color: rgba(255,255,255,0.4); margin-top: 10rpx; }
.settlement-tip { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-bottom: 60rpx; }
.set-btn { width: 100%; height: 100rpx; border-radius: 50rpx; background: linear-gradient(90deg, #FF6B35, #E81CFF); color: #fff; font-weight: bold; }
/* Animations */
@keyframes cardDraw { from { transform: translateY(200rpx) scale(0.5); opacity: 0; } to { transform: translateY(0) scale(1.0); opacity: 1; } }
@keyframes energyFlow { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@keyframes shine { 0% { left: -100%; } 20% { left: 120%; } 100% { left: 120%; } }
@keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes pulse { 0% { opacity: 0.6; transform: scale(1); } 50% { opacity: 1; transform: scale(1.05); } 100% { opacity: 0.6; transform: scale(1); } }
.animate-fade-in { animation: fadeIn 0.8s ease-out; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.animate-slide-up { animation: slideUp 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) backwards; }
@keyframes slideUp { from { transform: translateY(100rpx); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.animate-bounce-in { animation: bounceIn 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) backwards; }
@keyframes bounceIn { 0% { transform: scale(0.3); opacity: 0; } 50% { transform: scale(1.05); opacity: 1; } 70% { transform: scale(0.9); } 100% { transform: scale(1); } }
.animate-zoom-in { animation: zoomIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) backwards; }
@keyframes zoomIn { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.matching-pulse { animation: mPulse 1.5s infinite; }
@keyframes mPulse { 0% { opacity: 1; } 50% { opacity: 0.6; } 100% { opacity: 1; } }
.pulsate { animation: pulsate 2s infinite ease-in-out; }
@keyframes pulsate {
0% { transform: scale(1); box-shadow: 0 15rpx 40rpx rgba(232, 28, 255, 0.4); }
50% { transform: scale(1.02); box-shadow: 0 25rpx 60rpx rgba(232, 28, 255, 0.6); }
100% { transform: scale(1); box-shadow: 0 15rpx 40rpx rgba(232, 28, 255, 0.4); }
}
</style>