Merge branch 'main' of https://git.1024tool.vip/zfc/bindbox-mini
This commit is contained in:
commit
9c3775624f
@ -292,3 +292,34 @@ export function getMatchingGameCards(game_id) {
|
||||
data: { game_id }
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 次数卡 (Game Pass) 接口
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取用户可用的次数卡
|
||||
* @param {number} activity_id - 活动ID,不传返回所有
|
||||
*/
|
||||
export function getGamePasses(activity_id) {
|
||||
const data = activity_id ? { activity_id } : {}
|
||||
return authRequest({ url: '/api/app/game-passes/available', method: 'GET', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可购买的次数卡套餐
|
||||
* @param {number} activity_id - 活动ID,不传返回全局套餐
|
||||
*/
|
||||
export function getGamePassPackages(activity_id) {
|
||||
const data = activity_id ? { activity_id } : {}
|
||||
return authRequest({ url: '/api/app/game-passes/packages', method: 'GET', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买次数卡套餐
|
||||
* @param {number} package_id - 套餐ID
|
||||
*/
|
||||
export function purchaseGamePass(package_id) {
|
||||
return authRequest({ url: '/api/app/game-passes/purchase', method: 'POST', data: { package_id } })
|
||||
}
|
||||
|
||||
|
||||
303
components/GamePassPurchasePopup.vue
Normal file
303
components/GamePassPurchasePopup.vue
Normal file
@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<view>
|
||||
<view v-if="visible" class="popup-mask" @tap="handleClose">
|
||||
<view class="popup-content" @tap.stop>
|
||||
<view class="popup-header">
|
||||
<text class="title">✨ 超值次数卡</text>
|
||||
<view class="close-btn" @tap="handleClose">×</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="packages-list">
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!packages.length" class="empty-state">
|
||||
<text>暂无优惠套餐</text>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
v-for="(pkg, index) in packages"
|
||||
:key="pkg.id"
|
||||
class="package-item"
|
||||
:class="{ 'best-value': pkg.is_best_value }"
|
||||
@tap="handlePurchase(pkg)"
|
||||
>
|
||||
<view class="pkg-tag" v-if="pkg.tag">{{ pkg.tag }}</view>
|
||||
<view class="pkg-left">
|
||||
<view class="pkg-name">{{ pkg.name }}</view>
|
||||
<view class="pkg-count">含 {{ pkg.pass_count }} 次游戏</view>
|
||||
<view class="pkg-validity" v-if="pkg.valid_days > 0">有效期 {{ pkg.valid_days }} 天</view>
|
||||
<view class="pkg-validity" v-else>永久有效</view>
|
||||
</view>
|
||||
<view class="pkg-right">
|
||||
<view class="pkg-price-row">
|
||||
<text class="currency">¥</text>
|
||||
<text class="price">{{ (pkg.price / 100).toFixed(2) }}</text>
|
||||
</view>
|
||||
<view class="pkg-original-price" v-if="pkg.original_price > pkg.price">
|
||||
¥{{ (pkg.original_price / 100).toFixed(2) }}
|
||||
</view>
|
||||
<button class="btn-buy" :loading="purchasingId === pkg.id">购买</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { getGamePassPackages, purchaseGamePass, createWechatOrder } from '@/api/appUser'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
activityId: { type: [String, Number], default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const packages = ref([])
|
||||
const purchasingId = ref(null)
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
fetchPackages()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchPackages() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getGamePassPackages(props.activityId)
|
||||
// res 应该是数组
|
||||
let list = []
|
||||
if (Array.isArray(res)) list = res
|
||||
else if (res && Array.isArray(res.list)) list = res.list
|
||||
else if (res && Array.isArray(res.data)) list = res.data
|
||||
|
||||
// 简单处理:给第一个或最优惠的打标签
|
||||
// 这里随机模拟一下 "热销" 或计算折扣力度
|
||||
packages.value = list.map(p => {
|
||||
let tag = ''
|
||||
const discount = 1 - (p.price / p.original_price)
|
||||
if (p.original_price > 0 && discount >= 0.2) {
|
||||
tag = `省${Math.floor(discount * 100)}%`
|
||||
}
|
||||
return { ...p, tag }
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
packages.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurchase(pkg) {
|
||||
if (purchasingId.value) return
|
||||
purchasingId.value = pkg.id
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '创建订单...' })
|
||||
|
||||
// 1. 调用购买接口 (后端创建订单 + 预下单)
|
||||
// 注意:根据后端实现,purchaseGamePass 可能直接返回支付参数,或者需要我们自己调 createWechatOrder
|
||||
// 之前分析 game_passes_app.go,它似乎返回的是 simple success?
|
||||
// 让我们再确认一下 game_passes_app.go 的 PurchaseGamePassPackage
|
||||
// 既然我看不到代码,按常规逻辑:
|
||||
// 如果返回 order_no,则需要 createWechatOrder
|
||||
// 如果返回 pay_params,则直接支付
|
||||
|
||||
// 假设 API 返回 { order_no, ... }
|
||||
const res = await purchaseGamePass(pkg.id)
|
||||
const orderNo = res.order_no || res.orderNo
|
||||
if (!orderNo) throw new Error('下单失败')
|
||||
|
||||
// 2. 拉起支付
|
||||
const openid = uni.getStorageSync('openid')
|
||||
const payRes = await createWechatOrder({ openid, order_no: orderNo })
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||
package: payRes.package,
|
||||
signType: payRes.signType || 'RSA',
|
||||
paySign: payRes.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
uni.showToast({ title: '购买成功', icon: 'success' })
|
||||
emit('success')
|
||||
handleClose()
|
||||
|
||||
} catch (e) {
|
||||
if (e?.errMsg && e.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: '取消支付', icon: 'none' })
|
||||
} else {
|
||||
uni.showToast({ title: e.message || '购买失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
purchasingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
width: 100%;
|
||||
background: #FFFFFF;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
padding: 32rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #F3F4F6;
|
||||
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
font-size: 48rpx;
|
||||
color: #9CA3AF;
|
||||
line-height: 0.8;
|
||||
padding: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.packages-list {
|
||||
padding: 32rpx;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 60rpx 0;
|
||||
color: #9CA3AF;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.package-item {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #FFFFFF, #F9FAFB);
|
||||
border: 2rpx solid #E5E7EB;
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
background: #F3F4F6;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-tag {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #FF6B00;
|
||||
color: #FFF;
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-bottom-right-radius: 12rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pkg-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pkg-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #1F2937;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.pkg-count {
|
||||
font-size: 26rpx;
|
||||
color: #4B5563;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.pkg-validity {
|
||||
font-size: 22rpx;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.pkg-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.pkg-price-row {
|
||||
color: #FF6B00;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4rpx;
|
||||
|
||||
.currency {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.price {
|
||||
font-size: 40rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-original-price {
|
||||
font-size: 22rpx;
|
||||
color: #9CA3AF;
|
||||
text-decoration: line-through;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.btn-buy {
|
||||
background: linear-gradient(90deg, #FF6B00, #FF9F43);
|
||||
color: #FFF;
|
||||
font-size: 24rpx;
|
||||
padding: 0 24rpx;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
border-radius: 28rpx;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
|
||||
&[loading] {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -32,7 +32,30 @@
|
||||
</view>
|
||||
|
||||
<view class="popup-body">
|
||||
<view class="amount-section" v-if="amount !== undefined && amount !== null">
|
||||
<!-- 次数卡选项(有数据时显示) -->
|
||||
<view v-if="gamePasses" class="game-pass-section">
|
||||
<view
|
||||
class="game-pass-option"
|
||||
:class="{ active: useGamePass, disabled: gamePassRemaining <= 0 }"
|
||||
@tap="gamePassRemaining > 0 ? toggleGamePass() : null"
|
||||
>
|
||||
<view class="game-pass-radio">
|
||||
<view v-if="useGamePass" class="radio-checked">✓</view>
|
||||
<view v-else-if="gamePassRemaining <= 0" class="radio-disabled" />
|
||||
</view>
|
||||
<view class="game-pass-info">
|
||||
<text class="game-pass-label" :class="{ 'text-disabled': gamePassRemaining <= 0 }">🎮 使用次数卡</text>
|
||||
<text class="game-pass-count" v-if="gamePassRemaining > 0">剩余 {{ gamePassRemaining }} 次</text>
|
||||
<text class="game-pass-count text-disabled" v-else>暂无可用次数卡</text>
|
||||
</view>
|
||||
<text v-if="gamePassRemaining > 0" class="game-pass-free">免费畅玩</text>
|
||||
</view>
|
||||
<view v-if="!useGamePass" class="divider-line">
|
||||
<text class="divider-text">或选择其他支付方式</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="amount-section" v-if="!useGamePass && amount !== undefined && amount !== null">
|
||||
<text class="label">支付金额</text>
|
||||
<text class="amount">¥{{ finalPayAmount }}</text>
|
||||
<text v-if="finalPayAmount < amount" class="original-amount" style="text-decoration: line-through; color: #999; font-size: 24rpx; margin-left: 10rpx;">¥{{ amount }}</text>
|
||||
@ -87,7 +110,8 @@
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="btn-cancel" @tap="handleClose">取消</button>
|
||||
<button class="btn-confirm" @tap="handleConfirm">确认支付</button>
|
||||
<button v-if="useGamePass" class="btn-confirm btn-game-pass" @tap="handleConfirm">🎮 使用次数卡</button>
|
||||
<button v-else class="btn-confirm" @tap="handleConfirm">确认支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -102,7 +126,8 @@ const props = defineProps({
|
||||
amount: { type: [Number, String], default: 0 },
|
||||
coupons: { type: Array, default: () => [] },
|
||||
propCards: { type: Array, default: () => [] },
|
||||
showCards: { type: Boolean, default: true }
|
||||
showCards: { type: Boolean, default: true },
|
||||
gamePasses: { type: Object, default: () => null } // { total_remaining, passes }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
|
||||
@ -174,6 +199,24 @@ watch(() => props.visible, (newVal) => {
|
||||
|
||||
const couponIndex = ref(-1)
|
||||
const cardIndex = ref(-1)
|
||||
const useGamePass = ref(false)
|
||||
|
||||
// 次数卡余额
|
||||
const gamePassRemaining = computed(() => {
|
||||
return props.gamePasses?.total_remaining || 0
|
||||
})
|
||||
|
||||
// 监听弹窗打开,若有次数卡则默认选中
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
// 若有次数卡,默认选中
|
||||
useGamePass.value = gamePassRemaining.value > 0
|
||||
}
|
||||
})
|
||||
|
||||
function toggleGamePass() {
|
||||
useGamePass.value = !useGamePass.value
|
||||
}
|
||||
|
||||
const selectedCoupon = computed(() => {
|
||||
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
|
||||
@ -254,8 +297,9 @@ function handleClose() {
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', {
|
||||
coupon: selectedCoupon.value,
|
||||
card: props.showCards ? selectedCard.value : null
|
||||
coupon: useGamePass.value ? null : selectedCoupon.value,
|
||||
card: (props.showCards && !useGamePass.value) ? selectedCard.value : null,
|
||||
useGamePass: useGamePass.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -765,4 +809,129 @@ function handleConfirm() {
|
||||
transform: scale(0.97);
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
/* 次数卡使用按钮特殊样式 */
|
||||
.btn-game-pass {
|
||||
background: linear-gradient(135deg, #10B981, #059669);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
次数卡选项样式
|
||||
============================================ */
|
||||
.game-pass-section {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.game-pass-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
background: linear-gradient(135deg, #ECFDF5, #D1FAE5);
|
||||
border: 2rpx solid #10B981;
|
||||
border-radius: $radius-lg;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #10B981, #059669);
|
||||
border-color: #059669;
|
||||
|
||||
.game-pass-label, .game-pass-count, .game-pass-free {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.game-pass-radio {
|
||||
background: #FFFFFF;
|
||||
border-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.radio-checked {
|
||||
color: #10B981;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: #F9FAFB;
|
||||
border-color: #E5E7EB;
|
||||
|
||||
.game-pass-radio {
|
||||
border-color: #D1D5DB;
|
||||
background: #F3F4F6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.game-pass-radio {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid #10B981;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: $spacing-sm;
|
||||
}
|
||||
|
||||
.radio-checked {
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
color: #10B981;
|
||||
}
|
||||
|
||||
.game-pass-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-pass-label {
|
||||
font-size: $font-md;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.game-pass-count {
|
||||
font-size: $font-sm;
|
||||
color: #10B981;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.game-pass-free {
|
||||
font-size: $font-sm;
|
||||
font-weight: 600;
|
||||
color: #10B981;
|
||||
padding: 6rpx 16rpx;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: $spacing-md;
|
||||
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1rpx;
|
||||
background: $border-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
font-size: $font-xs;
|
||||
color: $text-placeholder;
|
||||
padding: 0 $spacing-sm;
|
||||
}
|
||||
|
||||
.radio-disabled {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background: #D1D5DB;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: #9CA3AF !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -68,6 +68,17 @@
|
||||
<text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
|
||||
<text class="unit">/次</text>
|
||||
</view>
|
||||
|
||||
<!-- 次数卡余额 / 购买入口 -->
|
||||
<view v-if="gamePassRemaining > 0" class="game-pass-badge" @tap="onParticipate">
|
||||
<text class="badge-icon">🎮</text>
|
||||
<text class="badge-text" style="font-size: 24rpx; font-weight: bold; color: #10B981;">{{ gamePassRemaining }}</text>
|
||||
</view>
|
||||
<!-- 充值入口 -->
|
||||
<view class="game-pass-buy-btn" @tap="openPurchasePopup">
|
||||
<text>充值特惠</text>
|
||||
</view>
|
||||
|
||||
<view v-if="hasResumeGame" class="action-btn secondary" @tap="onResumeGame">
|
||||
继续游戏
|
||||
</view>
|
||||
@ -163,8 +174,16 @@
|
||||
:coupons="coupons"
|
||||
:propCards="propCards"
|
||||
:showCards="true"
|
||||
:gamePasses="gamePasses"
|
||||
@confirm="onPaymentConfirm"
|
||||
/>
|
||||
|
||||
<GamePassPurchasePopup
|
||||
v-model:visible="purchasePopupVisible"
|
||||
:activity-id="activityId"
|
||||
@success="onPurchaseSuccess"
|
||||
/>
|
||||
|
||||
<RulesPopup
|
||||
v-model:visible="rulesVisible"
|
||||
:content="detail.gameplay_intro"
|
||||
@ -189,6 +208,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import PaymentPopup from '../../../components/PaymentPopup.vue'
|
||||
import GamePassPurchasePopup from '../../../components/GamePassPurchasePopup.vue'
|
||||
import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue'
|
||||
import ActivityHeader from '@/components/activity/ActivityHeader.vue'
|
||||
import ActivityTabs from '@/components/activity/ActivityTabs.vue'
|
||||
@ -198,7 +218,7 @@ import RecordsList from '@/components/activity/RecordsList.vue'
|
||||
import RulesPopup from '@/components/activity/RulesPopup.vue'
|
||||
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
|
||||
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.vue'
|
||||
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs, getMatchingGameCards } from '../../../api/appUser'
|
||||
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs, getMatchingGameCards, getGamePasses } from '../../../api/appUser'
|
||||
import { levelToAlpha } from '@/utils/activity'
|
||||
import { vibrateShort } from '@/utils/vibrate.js'
|
||||
|
||||
@ -226,6 +246,10 @@ const rulesVisible = ref(false)
|
||||
const cabinetVisible = ref(false)
|
||||
const resultVisible = ref(false)
|
||||
const resultItems = ref([])
|
||||
const gamePasses = ref(null) // 次数卡数据 { total_remaining, passes }
|
||||
const gamePassRemaining = computed(() => gamePasses.value?.total_remaining || 0)
|
||||
const useGamePassFlag = ref(false) // 是否使用次数卡支付
|
||||
const purchasePopupVisible = ref(false)
|
||||
const resumeGame = ref(null)
|
||||
const resumeIssueId = ref('')
|
||||
const hasResumeGame = computed(() => {
|
||||
@ -1317,6 +1341,7 @@ async function fetchCardTypes() {
|
||||
async function onPaymentConfirm(data) {
|
||||
selectedCoupon.value = data?.coupon || null
|
||||
selectedCard.value = data?.card || null
|
||||
useGamePassFlag.value = data?.useGamePass || false
|
||||
paymentVisible.value = false
|
||||
await doDraw()
|
||||
}
|
||||
@ -1336,12 +1361,13 @@ async function doDraw() {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 调用 createMatchingPreorder 创建对对碰订单(不再返回游戏数据)
|
||||
// 1. 调用 createMatchingPreorder 创建对对碰订单
|
||||
const preRes = await createMatchingPreorder({
|
||||
issue_id: Number(iid),
|
||||
position: String(selectedCardType.value.code || ''),
|
||||
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0,
|
||||
item_card_id: selectedCard.value?.id ? Number(selectedCard.value.id) : 0
|
||||
coupon_id: useGamePassFlag.value ? 0 : (selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0),
|
||||
item_card_id: useGamePassFlag.value ? 0 : (selectedCard.value?.id ? Number(selectedCard.value.id) : 0),
|
||||
use_game_pass: useGamePassFlag.value
|
||||
})
|
||||
if (!preRes) throw new Error('创建订单失败')
|
||||
|
||||
@ -1352,24 +1378,33 @@ async function doDraw() {
|
||||
const gameId = preRes.game_id || preRes.data?.game_id || preRes.result?.game_id || preRes.gameId
|
||||
if (!gameId) throw new Error('未获取到游戏ID')
|
||||
|
||||
// 3. 用对对碰订单号调用微信支付
|
||||
uni.showLoading({ title: '拉起支付...' })
|
||||
const payRes = await createWechatOrder({ openid, order_no: orderNo })
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||
package: payRes.package,
|
||||
signType: payRes.signType || 'RSA',
|
||||
paySign: payRes.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
// 3. 判断支付方式:次数卡已直接支付,微信需要拉起支付
|
||||
const payStatus = preRes.pay_status || preRes.data?.pay_status || 1
|
||||
if (useGamePassFlag.value || payStatus === 2) {
|
||||
// 次数卡支付:订单已经是已支付状态,直接获取游戏数据
|
||||
uni.showLoading({ title: '加载游戏...' })
|
||||
// 刷新次数卡余额
|
||||
await fetchGamePasses()
|
||||
} else {
|
||||
// 微信支付流程
|
||||
uni.showLoading({ title: '拉起支付...' })
|
||||
const payRes = await createWechatOrder({ openid, order_no: orderNo })
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: payRes.timeStamp || payRes.timestamp,
|
||||
nonceStr: payRes.nonceStr || payRes.noncestr,
|
||||
package: payRes.package,
|
||||
signType: payRes.signType || 'RSA',
|
||||
paySign: payRes.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
})
|
||||
uni.showLoading({ title: '加载游戏...' })
|
||||
}
|
||||
|
||||
// 4. 【关键】支付成功后,调用新接口获取游戏数据
|
||||
uni.showLoading({ title: '加载游戏...' })
|
||||
// 4. 获取游戏数据
|
||||
const cardsRes = await getMatchingGameCards(gameId)
|
||||
if (!cardsRes) throw new Error('获取游戏数据失败')
|
||||
|
||||
@ -1385,7 +1420,7 @@ async function doDraw() {
|
||||
})
|
||||
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||
uni.showToast({ title: useGamePassFlag.value ? '开始游戏' : '支付成功', icon: 'success' })
|
||||
|
||||
// 6. 自动打开游戏
|
||||
syncResumeGame(aid)
|
||||
@ -1426,6 +1461,26 @@ async function fetchCoupons() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGamePasses() {
|
||||
const aid = activityId.value || ''
|
||||
if (!aid) return
|
||||
try {
|
||||
const res = await getGamePasses(Number(aid))
|
||||
gamePasses.value = res || null
|
||||
} catch (e) {
|
||||
gamePasses.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openPurchasePopup() {
|
||||
purchasePopupVisible.value = true
|
||||
}
|
||||
|
||||
function onPurchaseSuccess() {
|
||||
// 购买成功,刷新次数卡余额
|
||||
fetchGamePasses()
|
||||
}
|
||||
|
||||
async function fetchPropCards() {
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
if (!user_id) return
|
||||
@ -1478,6 +1533,8 @@ onLoad((opts) => {
|
||||
syncResumeGame(id)
|
||||
fetchDetail(id)
|
||||
fetchIssues(id)
|
||||
// 获取次数卡
|
||||
fetchGamePasses()
|
||||
}
|
||||
fetchCardTypes()
|
||||
})
|
||||
@ -2470,3 +2527,53 @@ onLoad((opts) => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 浮动操作栏扩展 - 充值按钮 & Badge */
|
||||
.game-pass-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 30rpx;
|
||||
border: 1rpx solid rgba(16, 185, 129, 0.3);
|
||||
margin: 0 $spacing-sm;
|
||||
animation: pulse 2s infinite;
|
||||
|
||||
.badge-icon {
|
||||
font-size: 28rpx;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
font-size: 24rpx;
|
||||
color: #10B981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.game-pass-buy-btn {
|
||||
background: linear-gradient(90deg, #FF9F43, #FF6B00);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 24rpx;
|
||||
margin-right: 12rpx;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4rpx 8rpx rgba(255, 107, 0, 0.2);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 背景 -->
|
||||
<view class="bg-gradient"></view>
|
||||
<!-- 背景装饰 -->
|
||||
<view class="bg-decoration"></view>
|
||||
|
||||
<!-- 头部 -->
|
||||
<view class="header">
|
||||
<view class="back-btn" @tap="goBack">
|
||||
<text class="back-icon">←</text>
|
||||
</view>
|
||||
<text class="title">🎮 动物扫雷大作战</text>
|
||||
<text class="title">动物扫雷大作战</text>
|
||||
</view>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<view class="content">
|
||||
<!-- 游戏图标 -->
|
||||
<view class="game-icon-box">
|
||||
<view class="game-icon">💣</view>
|
||||
<view class="game-icon-box fadeInUp">
|
||||
<text class="game-icon">💣</text>
|
||||
<view class="game-glow"></view>
|
||||
</view>
|
||||
|
||||
<!-- 游戏介绍 -->
|
||||
<view class="intro-card">
|
||||
<view class="glass-card intro-card fadeInUp" style="animation-delay: 0.1s;">
|
||||
<text class="intro-title">多人对战扫雷</text>
|
||||
<text class="intro-desc">和好友一起挑战,获胜赢取精美奖品!</text>
|
||||
</view>
|
||||
|
||||
<!-- 资格显示 -->
|
||||
<view class="ticket-card" v-if="!loading">
|
||||
<view class="glass-card ticket-card fadeInUp" v-if="!loading" style="animation-delay: 0.2s;">
|
||||
<view class="ticket-row">
|
||||
<text class="ticket-label">我的游戏资格</text>
|
||||
<view class="ticket-count-box">
|
||||
@ -34,7 +34,8 @@
|
||||
<text class="ticket-unit">次</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="ticket-tip" v-if="ticketCount === 0">完成任务可获得游戏资格哦~</text>
|
||||
<view class="divider"></view>
|
||||
<text class="ticket-tip">{{ ticketCount > 0 ? '每次进入消耗1次资格' : '完成任务可获得游戏资格哦~' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
@ -46,13 +47,12 @@
|
||||
<!-- 底部按钮 -->
|
||||
<view class="footer">
|
||||
<view
|
||||
class="enter-btn"
|
||||
class="btn-primary"
|
||||
:class="{ disabled: ticketCount <= 0 || entering }"
|
||||
@tap="enterGame"
|
||||
>
|
||||
<text class="enter-btn-text">{{ entering ? '进入中...' : '进入游戏' }}</text>
|
||||
<text class="enter-btn-text">{{ entering ? '正在进入...' : (ticketCount > 0 ? '立即开局' : '资格不足') }}</text>
|
||||
</view>
|
||||
<text class="footer-tip">每次进入消耗1次资格</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@ -81,20 +81,14 @@ export default {
|
||||
try {
|
||||
const userInfo = uni.getStorageSync('user_info') || {}
|
||||
const userId = userInfo.id || userInfo.user_id
|
||||
console.log('===== 调试游戏资格 =====')
|
||||
console.log('userId:', userId)
|
||||
if (!userId) {
|
||||
console.log('未获取到用户ID,返回0')
|
||||
this.ticketCount = 0
|
||||
return
|
||||
}
|
||||
const res = await authRequest({
|
||||
url: `/api/app/users/${userId}/game_tickets`
|
||||
})
|
||||
console.log('API返回:', res)
|
||||
// res 格式: { minesweeper: 3, poker: 0, ... }
|
||||
this.ticketCount = res[this.gameCode] || 0
|
||||
console.log('gameCode:', this.gameCode, 'ticketCount:', this.ticketCount)
|
||||
} catch (e) {
|
||||
console.error('加载游戏资格失败', e)
|
||||
this.ticketCount = 0
|
||||
@ -115,16 +109,12 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
// 构建游戏URL,传递安全认证参数
|
||||
const gameBaseUrl = res.client_url || 'https://game.1024tool.vip'
|
||||
const gameToken = encodeURIComponent(res.game_token)
|
||||
const nakamaServer = encodeURIComponent(res.nakama_server)
|
||||
const nakamaKey = encodeURIComponent(res.nakama_key)
|
||||
const gameUrl = `${gameBaseUrl}?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
|
||||
|
||||
// 跳转到webview
|
||||
uni.navigateTo({
|
||||
url: `/pages-game/game/webview?url=${encodeURIComponent(gameUrl)}`
|
||||
url: `/pages-game/game/minesweeper/play?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
@ -133,7 +123,6 @@ export default {
|
||||
})
|
||||
} finally {
|
||||
this.entering = false
|
||||
// 刷新资格数
|
||||
this.loadTickets()
|
||||
}
|
||||
}
|
||||
@ -142,47 +131,44 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/uni.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $bg-page;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, #E0C3FC 0%, #8EC5FC 50%, #E0E7FF 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 32rpx;
|
||||
padding-top: calc(100rpx + env(safe-area-inset-top));
|
||||
padding-top: calc(80rpx + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
font-size: 40rpx;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.title {
|
||||
@ -190,71 +176,67 @@ export default {
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #333;
|
||||
margin-right: 72rpx; // 平衡返回按钮
|
||||
color: $text-main;
|
||||
margin-right: 80rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40rpx 48rpx;
|
||||
padding: 40rpx 40rpx;
|
||||
}
|
||||
|
||||
.game-icon-box {
|
||||
position: relative;
|
||||
margin-bottom: 48rpx;
|
||||
margin: 60rpx 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-icon {
|
||||
font-size: 160rpx;
|
||||
animation: bounce 2s ease-in-out infinite;
|
||||
font-size: 180rpx;
|
||||
animation: float 4s ease-in-out infinite;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.game-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.6) 0%, transparent 70%);
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
background: radial-gradient(circle, rgba($brand-primary, 0.25) 0%, transparent 70%);
|
||||
filter: blur(20rpx);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 32rpx;
|
||||
padding: 48rpx;
|
||||
width: 100%;
|
||||
padding: 48rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #333;
|
||||
font-size: 44rpx;
|
||||
font-weight: 900;
|
||||
color: $brand-primary;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.intro-desc {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
color: $text-sub;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ticket-card {
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx 40rpx;
|
||||
width: 100%;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.ticket-row {
|
||||
@ -264,9 +246,9 @@ export default {
|
||||
}
|
||||
|
||||
.ticket-label {
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
font-size: 32rpx;
|
||||
color: $text-main;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ticket-count-box {
|
||||
@ -275,80 +257,64 @@ export default {
|
||||
}
|
||||
|
||||
.ticket-count {
|
||||
font-size: 56rpx;
|
||||
font-size: 64rpx;
|
||||
font-weight: 900;
|
||||
color: #7C3AED;
|
||||
color: $brand-primary;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.ticket-unit {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
margin-left: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(0,0,0,0.05);
|
||||
margin: 32rpx 0;
|
||||
}
|
||||
|
||||
.ticket-tip {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 16rpx;
|
||||
color: $text-sub;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-box {
|
||||
padding: 60rpx;
|
||||
padding: 100rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
color: $text-sub;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 32rpx 48rpx;
|
||||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
z-index: 10;
|
||||
padding: 40rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
background: linear-gradient(135deg, #7C3AED 0%, #9F7AEA 100%);
|
||||
border-radius: 48rpx;
|
||||
padding: 32rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(124, 58, 237, 0.4);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.enter-btn:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 4rpx 12rpx rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.enter-btn.disabled {
|
||||
background: #CCC;
|
||||
box-shadow: none;
|
||||
.btn-primary {
|
||||
height: 110rpx;
|
||||
width: 100%;
|
||||
|
||||
&.disabled {
|
||||
background: $text-disabled;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.enter-btn-text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
font-size: 36rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.footer-tip {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20rpx); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.1); }
|
||||
/* Animations from App.vue are global, but we use local ones if needed */
|
||||
.fadeInUp {
|
||||
animation: fadeInUp 0.6s ease-out both;
|
||||
}
|
||||
</style>
|
||||
|
||||
1024
pages-game/game/minesweeper/play.scss
Normal file
1024
pages-game/game/minesweeper/play.scss
Normal file
File diff suppressed because it is too large
Load Diff
596
pages-game/game/minesweeper/play.vue
Normal file
596
pages-game/game/minesweeper/play.vue
Normal file
@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<view class="container" :class="{ 'screen-shake': screenShaking }">
|
||||
<!-- 背景 -->
|
||||
<view class="bg-dark-grid"></view>
|
||||
|
||||
<!-- 连接中状态 -->
|
||||
<view v-if="!isConnected" class="loading-screen">
|
||||
<view class="loading-content">
|
||||
<view class="loading-spinner">📡</view>
|
||||
<text class="loading-text">正在寻找基站...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 匹配大厅 -->
|
||||
<view v-else-if="!gameState" class="lobby-screen">
|
||||
<view class="bg-decoration"></view>
|
||||
|
||||
<view class="lobby-content fadeInUp">
|
||||
<text class="game-title">动物扫雷大作战</text>
|
||||
|
||||
<view v-if="isMatching" class="match-box glass-card">
|
||||
<view class="scanner-box pulse">
|
||||
<view class="scanner-line"></view>
|
||||
<text class="match-spinner">🦁</text>
|
||||
</view>
|
||||
<text class="match-status">正在匹配全球玩家...</text>
|
||||
|
||||
<view class="match-info">
|
||||
<view class="timer-row">
|
||||
<text class="timer-label">等待时长</text>
|
||||
<text class="timer-value">{{ matchingTimer }}s</text>
|
||||
</view>
|
||||
|
||||
<view class="status-tip">
|
||||
<text v-if="matchingTimer <= 30" class="tip-normal">⚡ 正在接入星际网道</text>
|
||||
<text v-else-if="matchingTimer <= 60" class="tip-warning">⏳ 搜索频率增强中...</text>
|
||||
<text v-else class="tip-error">⚠️ 信号微弱,请重试</text>
|
||||
</view>
|
||||
|
||||
<view class="btn-secondary cancel-btn" @tap="cancelMatchmaking">
|
||||
取消匹配
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="start-box">
|
||||
<view class="game-intro-card glass-card">
|
||||
<view class="intro-item">
|
||||
<text class="intro-icon">🎮</text>
|
||||
<text class="intro-text">{{ matchPlayerCount }}人经典竞技模式</text>
|
||||
</view>
|
||||
<view class="intro-item">
|
||||
<text class="intro-icon">🏆</text>
|
||||
<text class="intro-text">胜者赢取全额奖励</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="btn-primary start-btn" @tap="startMatchmaking">
|
||||
<text class="btn-text">🚀 开始匹配</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 游戏日志 -->
|
||||
<view class="log-section glass-card">
|
||||
<view class="log-header">
|
||||
<text class="log-title">通讯终端 (LIVE)</text>
|
||||
<view class="online-indicator"></view>
|
||||
</view>
|
||||
<scroll-view scroll-y class="mini-logs" :scroll-top="logsScrollTop">
|
||||
<view v-for="log in logs" :key="log.id" class="log-item" :class="'log-' + log.type">
|
||||
<text class="log-time">[{{ log.time }}]</text>
|
||||
<text class="log-content">{{ log.content }}</text>
|
||||
</view>
|
||||
<view v-if="logs.length === 0" class="log-empty">等待操作指令...</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 游戏主界面 -->
|
||||
<view v-else class="game-screen">
|
||||
<!-- 顶部状态栏 -->
|
||||
<view class="game-header">
|
||||
<view class="round-badge">Round {{ gameState.round }}</view>
|
||||
<text class="header-title">动物扫雷</text>
|
||||
<view class="header-actions">
|
||||
<view class="btn-icon" @tap="showGuide = true">📜</view>
|
||||
<view class="btn-icon" :class="{ active: debugMode }" @tap="debugMode = !debugMode">🐛</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="game-layout">
|
||||
<!-- 对手列表 (横向滚动) -->
|
||||
<scroll-view scroll-x class="opponents-bar">
|
||||
<view class="opponents-list">
|
||||
<view
|
||||
v-for="p in opponents"
|
||||
:key="p.userId"
|
||||
class="player-card opponent"
|
||||
:class="{
|
||||
'active-turn': gameState.turnOrder[gameState.currentTurnIndex] === p.userId,
|
||||
'damaged': damagedPlayers.includes(p.userId),
|
||||
'healed': healedPlayers.includes(p.userId)
|
||||
}"
|
||||
>
|
||||
<text class="avatar">{{ p.avatar }}</text>
|
||||
<view class="player-info">
|
||||
<text class="username">{{ p.username || '对手' }}</text>
|
||||
<view class="hp-bar">
|
||||
<text
|
||||
v-for="(n, i) in p.maxHp"
|
||||
:key="i"
|
||||
class="heart"
|
||||
:class="{ filled: i < p.hp }"
|
||||
>{{ i < p.hp ? '❤️' : '🤍' }}</text>
|
||||
</view>
|
||||
<view class="status-icons">
|
||||
<text v-if="p.shield" class="icon">🛡️</text>
|
||||
<text v-if="p.poisoned" class="icon">☠️</text>
|
||||
<text v-if="p.curse" class="icon">👻</text>
|
||||
<text v-if="p.revive" class="icon">💖</text>
|
||||
<text v-if="p.timeBombTurns > 0" class="icon pulse">⏰{{p.timeBombTurns}}</text>
|
||||
<text v-if="p.skipTurn" class="icon">⏭️</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 玩家身上的飘字 -->
|
||||
<view v-for="l in getPlayerLabels(p.userId)" :key="l.id" class="float-label" :class="'text-' + l.type" style="top: 20%; left: 50%; transform: translateX(-50%);">
|
||||
{{ l.text }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 棋盘区 -->
|
||||
<view class="board-area">
|
||||
<view class="turn-indicator" v-if="gameState.gameStarted">
|
||||
<view class="turn-badge" :class="{ 'my-turn pulse': isMyTurn }">
|
||||
{{ isMyTurn ? '您的回合' : '等待对手...' }}
|
||||
</view>
|
||||
<view class="timer-badge" :class="{ urgent: turnTimer < 5 }">
|
||||
⏱️ {{ turnTimer }}s
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="timer-progress-bg" v-if="gameState.gameStarted">
|
||||
<view
|
||||
class="timer-progress-fill"
|
||||
:style="{ width: (turnTimer / 15 * 100) + '%' }"
|
||||
:class="turnTimer < 5 ? 'bg-red' : (turnTimer < 10 ? 'bg-yellow' : 'bg-green')"
|
||||
></view>
|
||||
</view>
|
||||
|
||||
<view class="grid-board" :style="{ gridTemplateColumns: 'repeat(' + gameState.gridSize + ', 1fr)' }">
|
||||
<view
|
||||
v-for="(cell, i) in gameState.grid"
|
||||
:key="i"
|
||||
class="grid-cell"
|
||||
:class="{
|
||||
'revealed': cell.revealed,
|
||||
'type-bomb': cell.revealed && cell.type === 'bomb',
|
||||
'bg-slate-800': !cell.revealed,
|
||||
'has-magnifier': myPlayer && myPlayer.revealedCells && myPlayer.revealedCells[i]
|
||||
}"
|
||||
@tap="handleCellClick(i)"
|
||||
>
|
||||
<view v-if="cell.revealed">
|
||||
<text v-if="cell.type === 'bomb'" class="cell-icon">💣</text>
|
||||
<text v-else-if="cell.type === 'empty'" class="cell-num" :style="{ color: getNumColor(cell.neighborBombs) }">
|
||||
{{ cell.neighborBombs > 0 ? cell.neighborBombs : '' }}
|
||||
</text>
|
||||
<text v-else-if="cell.type === 'item'" class="cell-icon">{{ getItemIcon(cell.itemId) }}</text>
|
||||
</view>
|
||||
<view v-else-if="myPlayer && myPlayer.revealedCells && myPlayer.revealedCells[i]" class="magnifier-mark">
|
||||
<text class="cell-icon magnifier-content">{{ getContentIcon(myPlayer.revealedCells[i]) }}</text>
|
||||
<text class="magnifier-badge">🔍</text>
|
||||
</view>
|
||||
|
||||
<!-- 格子上的飘字 -->
|
||||
<view v-for="l in getCellLabels(i)" :key="l.id" class="float-label" :class="'text-' + l.type">
|
||||
{{ l.text }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部面板 -->
|
||||
<view class="bottom-panel">
|
||||
<view v-if="myPlayer" class="player-card me"
|
||||
:class="{
|
||||
'active-turn': isMyTurn,
|
||||
'damaged': damagedPlayers.includes(myPlayer.userId),
|
||||
'healed': healedPlayers.includes(myPlayer.userId)
|
||||
}"
|
||||
>
|
||||
<text class="avatar lg">{{ myPlayer.avatar }}</text>
|
||||
<view class="player-info">
|
||||
<text class="username font-bold">我</text>
|
||||
<view class="hp-bar">
|
||||
<text
|
||||
v-for="(n, i) in myPlayer.maxHp"
|
||||
:key="i"
|
||||
class="heart"
|
||||
:class="{ filled: i < myPlayer.hp }"
|
||||
>{{ i < myPlayer.hp ? '❤️' : '🤍' }}</text>
|
||||
</view>
|
||||
<view class="status-icons">
|
||||
<text v-if="myPlayer.shield" class="icon">🛡️</text>
|
||||
<text v-if="myPlayer.poisoned" class="icon">☠️</text>
|
||||
<text v-if="myPlayer.curse" class="icon">👻</text>
|
||||
<text v-if="myPlayer.revive" class="icon">💖</text>
|
||||
<text v-if="myPlayer.timeBombTurns > 0" class="icon pulse">⏰{{myPlayer.timeBombTurns}}</text>
|
||||
<text v-if="myPlayer.skipTurn" class="icon">⏭️</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我身上的飘字 -->
|
||||
<view v-for="l in getPlayerLabels(myPlayer.userId)" :key="l.id" class="float-label" :class="'text-' + l.type" style="top: -20rpx; left: 50%; transform: translateX(-50%);">
|
||||
{{ l.text }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 迷你日志栏 -->
|
||||
<scroll-view scroll-y class="game-logs" :scroll-top="logsScrollTop">
|
||||
<view v-for="log in logs" :key="log.id" class="log-line" :class="'log-' + log.type">
|
||||
{{ log.content }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 结算弹窗 -->
|
||||
<view v-if="!gameState?.gameStarted && gameState?.winnerId" class="modal-overlay">
|
||||
<view class="modal-content glass-card">
|
||||
<text class="modal-emoji">{{ gameState.winnerId === myUserId ? '🏆' : '💀' }}</text>
|
||||
<text class="modal-title">{{ gameState.winnerId === myUserId ? '胜利!' : '很遗憾失败了' }}</text>
|
||||
<view
|
||||
class="btn-primary"
|
||||
:class="{ disabled: isRefreshing }"
|
||||
@tap="refreshAndPlayAgain"
|
||||
>
|
||||
<text class="btn-text">🚀 再来一局</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 玩法说明弹窗 -->
|
||||
<view v-if="showGuide" class="modal-overlay" @tap="showGuide = false">
|
||||
<view class="guide-modal glass-card" @tap.stop>
|
||||
<view class="guide-header">
|
||||
<text class="guide-title">核心战书</text>
|
||||
<view class="close-btn" @tap="showGuide = false">✕</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class="guide-body">
|
||||
<text class="section-title">🛡️ 道具百科</text>
|
||||
<view class="guide-grid">
|
||||
<view class="guide-item" v-for="(item, i) in guideItems" :key="'i'+i">
|
||||
<text class="icon">{{ item.i }}</text>
|
||||
<view class="desc">
|
||||
<text class="name">{{ item.n }}</text>
|
||||
<text class="detail">{{ item.d }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="section-title" style="margin-top: 32rpx;">🐾 角色天赋</text>
|
||||
<view class="guide-grid">
|
||||
<view class="guide-item" v-for="(item, i) in characterGuides" :key="'c'+i">
|
||||
<text class="icon">{{ item.i }}</text>
|
||||
<view class="desc">
|
||||
<text class="name">{{ item.n }}</text>
|
||||
<text class="detail">{{ item.d }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 全局飘字 -->
|
||||
<view v-for="l in globalLabels" :key="l.id" class="float-label global" :style="{ top: l.y + 'px', left: l.x + 'px' }" :class="'text-' + l.type">
|
||||
{{ l.text }}
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { nakamaManager } from '../../../utils/nakamaManager.js';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
gameState: null,
|
||||
logs: [],
|
||||
isConnected: false,
|
||||
isMatching: false,
|
||||
matchId: null,
|
||||
myUserId: null,
|
||||
floatingLabels: [],
|
||||
showGuide: false,
|
||||
debugMode: false,
|
||||
matchingTimer: 0,
|
||||
matchPlayerCount: 4,
|
||||
turnTimer: 15,
|
||||
screenShaking: false,
|
||||
damagedPlayers: [],
|
||||
healedPlayers: [],
|
||||
isRefreshing: false,
|
||||
logsScrollTop: 0,
|
||||
// Timers
|
||||
matchInterval: null,
|
||||
turnInterval: null,
|
||||
|
||||
guideItems: [
|
||||
{ i: '💊', n: '医疗包', d: '回复1点HP并清除中毒' },
|
||||
{ i: '🛡️', n: '护盾', d: '抵挡下次伤害' },
|
||||
{ i: '🔍', n: '放大镜', d: '透视1个格子' },
|
||||
{ i: '🔪', n: '匕首', d: '对随机对手造成1点伤害' },
|
||||
{ i: '⚡', n: '闪电', d: '全员造成1点伤害' },
|
||||
{ i: '⏭️', n: '好人卡', d: '获得护盾,跳过回合' },
|
||||
{ i: '💖', n: '复活', d: 'HP归零时复活' },
|
||||
{ i: '⏰', n: '定时炸弹', d: '3回合后爆炸' },
|
||||
{ i: '☠️', n: '毒药', d: '对手中毒每步扣血' },
|
||||
{ i: '👻', n: '诅咒', d: '特定操作触发扣血' },
|
||||
{ i: '📦', n: '宝箱', d: '随机大奖' },
|
||||
],
|
||||
characterGuides: [
|
||||
{ i: '🐶', n: '小狗', d: '忠诚:每移动一定步数必触发放大镜效果' },
|
||||
{ i: '🐘', n: '大象', d: '执拗:无法回复生命,但基础HP更高' },
|
||||
{ i: '🐯', n: '虎哥', d: '猛攻:匕首进化为全屏范围伤害' },
|
||||
{ i: '🐵', n: '猴子', d: '敏锐:每次点击都有概率发现香蕉(回血)' },
|
||||
{ i: '🦥', n: '树懒', d: '迟缓:翻到炸弹时伤害减半(扣1点)' },
|
||||
{ i: '🦛', n: '河马', d: '大胃:无法直接捡起道具卡' },
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
myPlayer() {
|
||||
if (!this.gameState || !this.gameState.players || !this.myUserId) return null;
|
||||
return this.gameState.players[this.myUserId];
|
||||
},
|
||||
opponents() {
|
||||
if (!this.gameState || !this.gameState.players || !this.myUserId) return [];
|
||||
return Object.values(this.gameState.players).filter(p => p.userId !== this.myUserId);
|
||||
},
|
||||
isMyTurn() {
|
||||
if (!this.gameState) return false;
|
||||
return this.gameState.turnOrder[this.gameState.currentTurnIndex] === this.myUserId;
|
||||
},
|
||||
globalLabels() {
|
||||
return this.floatingLabels.filter(l => l.cellIndex === undefined && l.targetUserId === undefined);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
logs() {
|
||||
this.$nextTick(() => {
|
||||
this.logsScrollTop = 99999 + Math.random();
|
||||
});
|
||||
},
|
||||
'gameState.currentTurnIndex'() {
|
||||
this.resetTurnTimer();
|
||||
},
|
||||
'gameState.gameStarted'(val) {
|
||||
if (val) this.resetTurnTimer();
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.fetchGameConfig();
|
||||
const { game_token, nakama_server, nakama_key } = options;
|
||||
if (game_token) {
|
||||
this.initNakama(game_token, decodeURIComponent(nakama_server || ''), decodeURIComponent(nakama_key || ''));
|
||||
} else {
|
||||
uni.showToast({ title: '参数错误', icon: 'none' });
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
this.cleanup();
|
||||
},
|
||||
methods: {
|
||||
async fetchGameConfig() {
|
||||
try {
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: 'https://game.1024tool.vip/api/internal/game/minesweeper/config',
|
||||
header: { 'X-Internal-Key': 'bindbox-internal-secret-2024' },
|
||||
success: (res) => resolve(res),
|
||||
fail: (err) => reject(err)
|
||||
})
|
||||
});
|
||||
if (res.statusCode === 200 && res.data) {
|
||||
const config = res.data;
|
||||
if (config.match_player_count && config.match_player_count >= 2) {
|
||||
this.matchPlayerCount = config.match_player_count;
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('Config fetch failed');
|
||||
}
|
||||
},
|
||||
getCellLabels(idx) {
|
||||
return this.floatingLabels.filter(l => l.cellIndex === idx);
|
||||
},
|
||||
getPlayerLabels(userId) {
|
||||
return this.floatingLabels.filter(l => l.targetUserId === userId);
|
||||
},
|
||||
addLog(type, content) {
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
|
||||
const id = Date.now() + Math.random().toString();
|
||||
this.logs.push({ id, type, content, time: timeStr });
|
||||
if (this.logs.length > 50) this.logs.shift();
|
||||
},
|
||||
spawnLabel(x, y, text, type, cellIndex, targetUserId) {
|
||||
const id = Date.now() + Math.random().toString();
|
||||
this.floatingLabels.push({ id, x, y, text, type, cellIndex, targetUserId });
|
||||
setTimeout(() => {
|
||||
this.floatingLabels = this.floatingLabels.filter(l => l.id !== id);
|
||||
}, 1000);
|
||||
},
|
||||
async initNakama(token, server, key) {
|
||||
try {
|
||||
const serverUrl = server || 'wss://game.1024tool.vip';
|
||||
const serverKey = key || 'defaultkey';
|
||||
nakamaManager.initClient(serverUrl, serverKey);
|
||||
const session = await nakamaManager.authenticateWithGameToken(token);
|
||||
this.myUserId = session.user_id;
|
||||
this.isConnected = true;
|
||||
this.addLog('system', '✅ 已连接到远程节点');
|
||||
// 先设置监听器,再继续后续操作
|
||||
this.setupSocketListeners();
|
||||
} catch (e) {
|
||||
this.addLog('system', '❌ 通讯异常: ' + e.message);
|
||||
}
|
||||
},
|
||||
setupSocketListeners() {
|
||||
nakamaManager.setListeners({
|
||||
onmatchmakermatched: async (matched) => {
|
||||
this.addLog('system', `📡 信号锁定!同步中...`);
|
||||
this.isMatching = false;
|
||||
clearInterval(this.matchInterval);
|
||||
|
||||
// 保存匹配信息用于可能的重连
|
||||
this.pendingMatchId = matched.match_id;
|
||||
this.pendingMatchToken = matched.token;
|
||||
|
||||
// 带重试的 joinMatch
|
||||
const maxRetries = 3;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const match = await nakamaManager.joinMatch(matched.match_id, matched.token);
|
||||
this.matchId = match.match_id;
|
||||
this.addLog('system', `成功接入战局`);
|
||||
setTimeout(() => {
|
||||
nakamaManager.sendMatchState(match.match_id, 100, JSON.stringify({ action: 'getState' }));
|
||||
}, 100);
|
||||
return; // 成功,退出重试循环
|
||||
} catch (e) {
|
||||
this.addLog('system', `⚠️ 接入尝试 ${i + 1}/${maxRetries} 失败: ${e.message}`);
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(r => setTimeout(r, 1000)); // 等待1秒后重试
|
||||
}
|
||||
}
|
||||
}
|
||||
this.addLog('system', `❌ 接入失败,请检查网络后重试`);
|
||||
},
|
||||
onmatchdata: (matchData) => {
|
||||
const opCode = matchData.op_code;
|
||||
const data = JSON.parse(new TextDecoder().decode(matchData.data));
|
||||
this.handleGameData(opCode, data);
|
||||
},
|
||||
ondisconnect: async () => {
|
||||
this.addLog('system', `⚠️ 连接断开,尝试重连中...`);
|
||||
this.isConnected = false;
|
||||
|
||||
// 自动重连
|
||||
try {
|
||||
await nakamaManager.authenticateWithGameToken(this.gameToken);
|
||||
this.isConnected = true;
|
||||
this.addLog('system', `✅ 重连成功`);
|
||||
|
||||
// 如果有进行中的比赛,尝试重新加入
|
||||
if (this.matchId) {
|
||||
const match = await nakamaManager.joinMatch(this.matchId);
|
||||
this.addLog('system', `✅ 已重新加入战局`);
|
||||
nakamaManager.sendMatchState(this.matchId, 100, JSON.stringify({ action: 'getState' }));
|
||||
}
|
||||
} catch (e) {
|
||||
this.addLog('system', `❌ 重连失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
handleGameData(opCode, data) {
|
||||
if (opCode === 1 || opCode === 2) {
|
||||
this.gameState = data;
|
||||
if (opCode === 1) this.addLog('system', '战局开始,准备翻格!');
|
||||
} else if (opCode === 5) {
|
||||
this.handleEvent(data);
|
||||
} else if (opCode === 6) {
|
||||
this.gameState = data;
|
||||
this.addLog('system', `战局结束:${data.winnerId === this.myUserId ? '您获得了胜利!' : '很遗憾失败了'}`);
|
||||
}
|
||||
},
|
||||
handleEvent(event) {
|
||||
if (event.type === 'damage' || event.type === 'heal' || event.type === 'item' || event.type === 'ability') {
|
||||
const isMe = event.targetUserId === this.myUserId;
|
||||
const iAmTarget = isMe;
|
||||
const attackerName = event.playerName || '对手';
|
||||
const itemDisplayName = this.guideItems.find(i => i.i === event.itemId)?.n || event.itemId;
|
||||
|
||||
let msg = '';
|
||||
if (event.type === 'item') msg = `发现了 ${itemDisplayName}`;
|
||||
else if (event.type === 'damage') msg = `受到了 ${event.value} 点伤害`;
|
||||
else if (event.type === 'heal') msg = `回复了 ${event.value} 点生命`;
|
||||
|
||||
if (isMe) this.addLog('effect', `指令提示: ${msg}`);
|
||||
else this.addLog('effect', `${attackerName.substring(0,8)}: ${msg}`);
|
||||
|
||||
if (event.type === 'damage' || event.type === 'item') {
|
||||
if (iAmTarget && event.value) this.spawnLabel(0, 0, `-${event.value}`, 'damage', undefined, this.myUserId);
|
||||
}
|
||||
if (event.type === 'damage' && iAmTarget) this.triggerDamageEffect(this.myUserId, event.value);
|
||||
}
|
||||
},
|
||||
triggerDamageEffect(uid, amount) {
|
||||
this.damagedPlayers.push(uid);
|
||||
setTimeout(() => this.damagedPlayers = this.damagedPlayers.filter(id => id !== uid), 600);
|
||||
if (uid === this.myUserId) {
|
||||
this.screenShaking = true;
|
||||
setTimeout(() => this.screenShaking = false, 400);
|
||||
}
|
||||
},
|
||||
async startMatchmaking() {
|
||||
if (this.isMatching) return;
|
||||
try {
|
||||
console.log('--- UI Click: Start Matchmaking ---');
|
||||
this.isMatching = true;
|
||||
this.matchingTimer = 0;
|
||||
this.logs = [];
|
||||
this.addLog('system', '🚀 发射匹配脉冲...');
|
||||
|
||||
clearInterval(this.matchInterval);
|
||||
this.matchInterval = setInterval(() => this.matchingTimer++, 1000);
|
||||
|
||||
await nakamaManager.findMatch(this.matchPlayerCount, this.matchPlayerCount);
|
||||
console.log('Matchmaker ticket requested');
|
||||
} catch (e) {
|
||||
console.error('Matchmaking error:', e);
|
||||
this.isMatching = false;
|
||||
clearInterval(this.matchInterval);
|
||||
this.addLog('system', '❌ 发射失败');
|
||||
}
|
||||
},
|
||||
cancelMatchmaking() {
|
||||
this.isMatching = false;
|
||||
clearInterval(this.matchInterval);
|
||||
this.addLog('system', '已切断匹配信号');
|
||||
},
|
||||
handleCellClick(idx) {
|
||||
if (!this.gameState || !this.gameState.gameStarted) return;
|
||||
if (!this.isMyTurn) return;
|
||||
if (this.gameState.grid[idx].revealed) return;
|
||||
nakamaManager.sendMatchState(this.matchId, 3, JSON.stringify({ index: idx }));
|
||||
},
|
||||
refreshAndPlayAgain() {
|
||||
uni.navigateBack();
|
||||
},
|
||||
resetTurnTimer() {
|
||||
this.turnTimer = 15;
|
||||
clearInterval(this.turnInterval);
|
||||
this.turnInterval = setInterval(() => {
|
||||
if (this.turnTimer > 0) this.turnTimer--;
|
||||
}, 1000);
|
||||
},
|
||||
cleanup() {
|
||||
clearInterval(this.matchInterval);
|
||||
clearInterval(this.turnInterval);
|
||||
nakamaManager.disconnect();
|
||||
},
|
||||
getItemIcon(itemId) {
|
||||
const map = { medkit: '💊', bomb_timer: '⏰', poison: '☠️', shield: '🛡️', skip: '⏭️', magnifier: '🔍', knife: '🔪', revive: '💖', lightning: '⚡', chest: '📦', curse: '👻' };
|
||||
return map[itemId] || '🎁';
|
||||
},
|
||||
getContentIcon(content) {
|
||||
if (content === 'bomb') return '💣';
|
||||
if (content === 'empty') return '✅';
|
||||
return this.getItemIcon(content);
|
||||
},
|
||||
getNumColor(n) { return ['','blue','green','red','purple','orange'][n] || 'black'; }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="./play.scss" lang="scss"></style>
|
||||
@ -43,6 +43,16 @@ function onMessage(e) {
|
||||
data.forEach(msg => {
|
||||
if (msg.action === 'close') {
|
||||
uni.navigateBack()
|
||||
} else if (msg.action === 'playAgain') {
|
||||
// 再来一局: 返回上一页,上一页会自动刷新重新获取token进入游戏
|
||||
console.log('PlayAgain: 返回游戏入口页面')
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
success: () => {
|
||||
// 可选: 发送事件通知上一页刷新
|
||||
uni.$emit('refreshGame')
|
||||
}
|
||||
})
|
||||
} else if (msg.action === 'game_over') {
|
||||
// Optional: Refresh user balance or state
|
||||
}
|
||||
|
||||
@ -139,6 +139,7 @@ const claiming = reactive({})
|
||||
// 用户进度 (汇总)
|
||||
const userProgress = reactive({
|
||||
orderCount: 0,
|
||||
orderAmount: 0,
|
||||
inviteCount: 0,
|
||||
firstOrder: false,
|
||||
claimedTiers: {} // { taskId: [tierId1, tierId2] }
|
||||
@ -224,6 +225,7 @@ function getTierBadge(tier) {
|
||||
const metric = tier.metric || ''
|
||||
if (metric === 'first_order') return '首'
|
||||
if (metric === 'order_count') return `${tier.threshold}单`
|
||||
if (metric === 'order_amount') return `¥${tier.threshold / 100}`
|
||||
if (metric === 'invite_count') return `${tier.threshold}人`
|
||||
return tier.threshold || ''
|
||||
}
|
||||
@ -233,6 +235,7 @@ function getTierConditionText(tier) {
|
||||
const metric = tier.metric || ''
|
||||
if (metric === 'first_order') return '完成首笔订单'
|
||||
if (metric === 'order_count') return `累计下单 ${tier.threshold} 笔`
|
||||
if (metric === 'order_amount') return `累计消费 ¥${tier.threshold / 100}`
|
||||
if (metric === 'invite_count') return `邀请 ${tier.threshold} 位好友`
|
||||
return `达成 ${tier.threshold}`
|
||||
}
|
||||
@ -285,6 +288,8 @@ function isTierClaimable(task, tier) {
|
||||
return userProgress.firstOrder
|
||||
} else if (metric === 'order_count') {
|
||||
current = userProgress.orderCount || 0
|
||||
} else if (metric === 'order_amount') {
|
||||
current = userProgress.orderAmount || 0
|
||||
} else if (metric === 'invite_count') {
|
||||
current = userProgress.inviteCount || 0
|
||||
}
|
||||
@ -305,6 +310,9 @@ function getTierProgressText(task, tier) {
|
||||
return userProgress.firstOrder ? '已完成' : '未完成'
|
||||
} else if (metric === 'order_count') {
|
||||
current = userProgress.orderCount || 0
|
||||
} else if (metric === 'order_amount') {
|
||||
current = userProgress.orderAmount || 0
|
||||
return `¥${current / 100}/¥${threshold / 100}`
|
||||
} else if (metric === 'invite_count') {
|
||||
current = userProgress.inviteCount || 0
|
||||
}
|
||||
@ -368,6 +376,7 @@ async function fetchData() {
|
||||
try {
|
||||
const progressRes = await getTaskProgress(list[0].id, userId)
|
||||
userProgress.orderCount = progressRes.order_count || 0
|
||||
userProgress.orderAmount = progressRes.order_amount || 0
|
||||
userProgress.inviteCount = progressRes.invite_count || 0
|
||||
userProgress.firstOrder = progressRes.first_order || false
|
||||
|
||||
|
||||
14
pages.json
14
pages.json
@ -3,7 +3,8 @@
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "柯大鸭"
|
||||
"navigationBarTitleText": "柯大鸭",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -177,6 +178,17 @@
|
||||
"navigationBarTitleText": "扫雷 game"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "game/minesweeper/play",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "扫雷对战",
|
||||
"disableScroll": true,
|
||||
"app-plus": {
|
||||
"bounce": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "game/webview",
|
||||
"style": {
|
||||
|
||||
@ -179,7 +179,14 @@ export default {
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.loadHomeData()
|
||||
// 延迟 200ms 首次加载,让 Token/Session 有机会就绪
|
||||
// 同时避免页面动画卡顿
|
||||
setTimeout(() => {
|
||||
this.loadHomeData()
|
||||
}, 200)
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.loadHomeData(true)
|
||||
},
|
||||
onShow() {
|
||||
// 只有非首次进入或数据为空时才触发刷新,避免 onLoad/onShow 双重触发
|
||||
@ -255,24 +262,55 @@ export default {
|
||||
const parts = [cat, price].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
},
|
||||
async loadHomeData() {
|
||||
if (this.isHomeLoading) return
|
||||
async loadHomeData(isRefresh = false) {
|
||||
if (this.isHomeLoading && !isRefresh) return
|
||||
this.isHomeLoading = true
|
||||
|
||||
// 定义重试函数
|
||||
const fetchWithRetry = async (url, retries = 3) => {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await this.apiGet(url)
|
||||
if (res) return res
|
||||
// 如果返回空,认为是失败,尝试重试
|
||||
if (i < retries - 1) await new Promise(r => setTimeout(r, 1000))
|
||||
} catch (e) {
|
||||
console.error(`Fetch ${url} failed, attempt ${i + 1}`, e)
|
||||
if (i < retries - 1) await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 同时发起请求,大幅提升首屏加载速度
|
||||
try {
|
||||
const [nData, bData, acData] = await Promise.all([
|
||||
this.apiGet('/api/app/notices').catch(() => null),
|
||||
this.apiGet('/api/app/banners').catch(() => null),
|
||||
this.apiGet('/api/app/activities').catch(() => null)
|
||||
fetchWithRetry('/api/app/notices'),
|
||||
fetchWithRetry('/api/app/banners'),
|
||||
fetchWithRetry('/api/app/activities')
|
||||
])
|
||||
|
||||
if (nData) this.notices = this.normalizeNotices(nData)
|
||||
if (bData) this.banners = this.normalizeBanners(bData)
|
||||
if (acData) this.activities = this.normalizeActivities(acData)
|
||||
|
||||
// 只有获取到有效活动数据才更新,保留旧数据兜底
|
||||
if (acData) {
|
||||
const validActivities = this.normalizeActivities(acData)
|
||||
if (validActivities.length > 0) {
|
||||
this.activities = validActivities
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Home data load failed', e)
|
||||
if (isRefresh) {
|
||||
uni.showToast({ title: '刷新失败,请稍后重试', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
this.isHomeLoading = false
|
||||
if (isRefresh) {
|
||||
uni.stopPullDownRefresh()
|
||||
uni.showToast({ title: '刷新成功', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
onBannerTap(b) {
|
||||
|
||||
71
utils/nakama-adapter.js
Normal file
71
utils/nakama-adapter.js
Normal file
@ -0,0 +1,71 @@
|
||||
// Nakama SDK 小程序适配器
|
||||
// 将 uni-app 的 connectSocket 和 Storage 适配到 Web 标准接口
|
||||
|
||||
export const WebSocketAdapter = {
|
||||
build: function (url) {
|
||||
const socketTask = uni.connectSocket({
|
||||
url: url,
|
||||
complete: () => { }
|
||||
});
|
||||
|
||||
const webSocket = {
|
||||
url: url,
|
||||
readyState: 0, // CONNECTING
|
||||
onopen: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
onmessage: null,
|
||||
send: (data) => {
|
||||
socketTask.send({
|
||||
data: data
|
||||
});
|
||||
},
|
||||
close: () => {
|
||||
socketTask.close();
|
||||
}
|
||||
};
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
webSocket.readyState = 1; // OPEN
|
||||
if (webSocket.onopen) {
|
||||
webSocket.onopen({ type: 'open' });
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onClose((res) => {
|
||||
webSocket.readyState = 3; // CLOSED
|
||||
if (webSocket.onclose) {
|
||||
webSocket.onclose({ code: res.code, reason: res.reason, wasClean: true });
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onError((err) => {
|
||||
if (webSocket.onerror) {
|
||||
webSocket.onerror({ error: err, message: err.errMsg });
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onMessage((res) => {
|
||||
if (webSocket.onmessage) {
|
||||
webSocket.onmessage({ data: res.data });
|
||||
}
|
||||
});
|
||||
|
||||
return webSocket;
|
||||
}
|
||||
};
|
||||
|
||||
export const localStorageAdapter = {
|
||||
getItem: (key) => {
|
||||
return uni.getStorageSync(key);
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
uni.setStorageSync(key, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
uni.removeStorageSync(key);
|
||||
},
|
||||
clear: () => {
|
||||
uni.clearStorageSync();
|
||||
}
|
||||
};
|
||||
5305
utils/nakama-js/nakama-js.js
Normal file
5305
utils/nakama-js/nakama-js.js
Normal file
File diff suppressed because it is too large
Load Diff
468
utils/nakamaManager.js
Normal file
468
utils/nakamaManager.js
Normal file
@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Nakama WebSocket Manager - 小程序直连版
|
||||
* 移除 SDK 依赖,直接使用原生 WebSocket 协议
|
||||
*/
|
||||
|
||||
class NakamaManager {
|
||||
constructor() {
|
||||
this.serverUrl = null;
|
||||
this.serverKey = 'defaultkey';
|
||||
this.useSSL = true;
|
||||
this.host = null;
|
||||
this.port = '443';
|
||||
|
||||
this.session = null;
|
||||
this.gameToken = null;
|
||||
this.socketTask = null;
|
||||
this.isConnected = false;
|
||||
|
||||
// 消息 ID 和待处理的 Promise
|
||||
this.nextCid = 1;
|
||||
this.pendingRequests = {};
|
||||
|
||||
// 事件监听器
|
||||
this.listeners = {
|
||||
onmatchmakermatched: null,
|
||||
onmatchdata: null,
|
||||
onmatchpresence: null,
|
||||
ondisconnect: null,
|
||||
onerror: null
|
||||
};
|
||||
|
||||
// 心跳定时器
|
||||
this.heartbeatTimer = null;
|
||||
this.heartbeatInterval = 10000; // 10秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化客户端配置
|
||||
*/
|
||||
initClient(serverUrl, serverKey = 'defaultkey') {
|
||||
this.serverKey = serverKey;
|
||||
this.serverUrl = serverUrl;
|
||||
|
||||
// 解析 URL
|
||||
const isWss = serverUrl.startsWith('wss://') || serverUrl.startsWith('https://');
|
||||
let host = serverUrl.replace('wss://', '').replace('ws://', '').replace('https://', '').replace('http://', '');
|
||||
let port = isWss ? '443' : '7350';
|
||||
|
||||
if (host.includes(':')) {
|
||||
const parts = host.split(':');
|
||||
host = parts[0];
|
||||
port = parts[1];
|
||||
}
|
||||
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.useSSL = isWss;
|
||||
|
||||
console.log(`[Nakama] Initialized: ${this.useSSL ? 'wss' : 'ws'}://${this.host}:${this.port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件监听器
|
||||
*/
|
||||
setListeners(config) {
|
||||
Object.keys(config).forEach(key => {
|
||||
if (this.listeners.hasOwnProperty(key)) {
|
||||
this.listeners[key] = config[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 game_token 认证
|
||||
*/
|
||||
async authenticateWithGameToken(gameToken) {
|
||||
this.gameToken = gameToken;
|
||||
|
||||
// 生成唯一的 custom ID
|
||||
const customId = `game_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
console.log('[Nakama] Authenticating with Custom ID:', customId);
|
||||
|
||||
// HTTP 认证请求
|
||||
const scheme = this.useSSL ? 'https://' : 'http://';
|
||||
const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`;
|
||||
const authUrl = `${scheme}${this.host}${portSuffix}/v2/account/authenticate/custom?create=true`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: authUrl,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': 'Basic ' + this._base64Encode(`${this.serverKey}:`),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: { id: customId },
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200 && res.data && res.data.token) {
|
||||
this.session = {
|
||||
token: res.data.token,
|
||||
refresh_token: res.data.refresh_token,
|
||||
user_id: this._parseUserIdFromToken(res.data.token)
|
||||
};
|
||||
console.log('[Nakama] Authenticated, user_id:', this.session.user_id);
|
||||
|
||||
// 认证成功后建立 WebSocket 连接
|
||||
this._connectWebSocket()
|
||||
.then(() => resolve(this.session))
|
||||
.catch(reject);
|
||||
} else {
|
||||
reject(new Error('Authentication failed: ' + JSON.stringify(res.data)));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new Error('Authentication request failed: ' + err.errMsg));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WebSocket 连接
|
||||
*/
|
||||
_connectWebSocket() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const scheme = this.useSSL ? 'wss://' : 'ws://';
|
||||
const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`;
|
||||
const wsUrl = `${scheme}${this.host}${portSuffix}/ws?lang=en&status=true&token=${encodeURIComponent(this.session.token)}`;
|
||||
|
||||
console.log('[Nakama] WebSocket connecting...');
|
||||
|
||||
this.socketTask = uni.connectSocket({
|
||||
url: wsUrl,
|
||||
complete: () => { }
|
||||
});
|
||||
|
||||
const connectTimeout = setTimeout(() => {
|
||||
reject(new Error('WebSocket connection timeout'));
|
||||
}, 15000);
|
||||
|
||||
this.socketTask.onOpen(() => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.isConnected = true;
|
||||
console.log('[Nakama] WebSocket connected');
|
||||
this._startHeartbeat();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socketTask.onClose((res) => {
|
||||
console.log('[Nakama] WebSocket closed:', res.code, res.reason);
|
||||
this.isConnected = false;
|
||||
this._stopHeartbeat();
|
||||
if (this.listeners.ondisconnect) {
|
||||
this.listeners.ondisconnect(res);
|
||||
}
|
||||
});
|
||||
|
||||
this.socketTask.onError((err) => {
|
||||
clearTimeout(connectTimeout);
|
||||
console.error('[Nakama] WebSocket error:', err);
|
||||
this.isConnected = false;
|
||||
if (this.listeners.onerror) {
|
||||
this.listeners.onerror(err);
|
||||
}
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
});
|
||||
|
||||
this.socketTask.onMessage((res) => {
|
||||
this._handleMessage(res.data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理收到的消息
|
||||
*/
|
||||
_handleMessage(rawData) {
|
||||
let message;
|
||||
try {
|
||||
message = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
||||
} catch (e) {
|
||||
console.error('[Nakama] Failed to parse message:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 有 cid 的消息是请求的响应
|
||||
if (message.cid) {
|
||||
const pending = this.pendingRequests[message.cid];
|
||||
if (pending) {
|
||||
delete this.pendingRequests[message.cid];
|
||||
clearTimeout(pending.timeout);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
||||
} else {
|
||||
pending.resolve(message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 无 cid 的消息是服务器主动推送
|
||||
if (message.matchmaker_matched) {
|
||||
console.log('[Nakama] Matchmaker matched:', message.matchmaker_matched.match_id);
|
||||
if (this.listeners.onmatchmakermatched) {
|
||||
this.listeners.onmatchmakermatched(message.matchmaker_matched);
|
||||
}
|
||||
} else if (message.match_data) {
|
||||
// 解码 base64 数据
|
||||
if (message.match_data.data) {
|
||||
message.match_data.data = this._base64ToUint8Array(message.match_data.data);
|
||||
}
|
||||
message.match_data.op_code = parseInt(message.match_data.op_code);
|
||||
if (this.listeners.onmatchdata) {
|
||||
this.listeners.onmatchdata(message.match_data);
|
||||
}
|
||||
} else if (message.match_presence_event) {
|
||||
if (this.listeners.onmatchpresence) {
|
||||
this.listeners.onmatchpresence(message.match_presence_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息并等待响应
|
||||
*/
|
||||
_send(message, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected || !this.socketTask) {
|
||||
reject(new Error('Socket not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const cid = String(this.nextCid++);
|
||||
message.cid = cid;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
delete this.pendingRequests[cid];
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests[cid] = { resolve, reject, timeout };
|
||||
|
||||
this.socketTask.send({
|
||||
data: JSON.stringify(message),
|
||||
fail: (err) => {
|
||||
delete this.pendingRequests[cid];
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Send failed: ' + err.errMsg));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(无需响应)
|
||||
*/
|
||||
_sendNoResponse(message) {
|
||||
if (!this.isConnected || !this.socketTask) {
|
||||
console.error('[Nakama] Cannot send, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socketTask.send({
|
||||
data: JSON.stringify(message),
|
||||
fail: (err) => {
|
||||
console.error('[Nakama] Send failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始匹配
|
||||
*/
|
||||
async findMatch(minCount, maxCount) {
|
||||
if (!this.isConnected) {
|
||||
console.log('[Nakama] Not connected, reconnecting...');
|
||||
await this.authenticateWithGameToken(this.gameToken);
|
||||
}
|
||||
|
||||
console.log('[Nakama] Adding to matchmaker:', minCount, '-', maxCount);
|
||||
const response = await this._send({
|
||||
matchmaker_add: {
|
||||
min_count: minCount || 2,
|
||||
max_count: maxCount || 2,
|
||||
query: '*',
|
||||
string_properties: { game_token: this.gameToken }
|
||||
}
|
||||
});
|
||||
console.log('[Nakama] Matchmaker ticket:', response.matchmaker_ticket);
|
||||
return response.matchmaker_ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入比赛
|
||||
*/
|
||||
async joinMatch(matchId, token) {
|
||||
console.log('[Nakama] Joining match:', matchId);
|
||||
const join = { match_join: {} };
|
||||
if (token) {
|
||||
join.match_join.token = token;
|
||||
} else {
|
||||
join.match_join.match_id = matchId;
|
||||
}
|
||||
|
||||
// 关键:传递 game_token 用于服务端验证
|
||||
join.match_join.metadata = { game_token: this.gameToken };
|
||||
|
||||
const response = await this._send(join);
|
||||
console.log('[Nakama] Joined match:', response.match?.match_id);
|
||||
return response.match;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送游戏状态
|
||||
*/
|
||||
sendMatchState(matchId, opCode, data) {
|
||||
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
this._sendNoResponse({
|
||||
match_data_send: {
|
||||
match_id: matchId,
|
||||
op_code: String(opCode),
|
||||
data: this._base64Encode(payload)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
this._stopHeartbeat();
|
||||
if (this.socketTask) {
|
||||
this.socketTask.close();
|
||||
this.socketTask = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.session = null;
|
||||
this.gameToken = null;
|
||||
console.log('[Nakama] Disconnected');
|
||||
}
|
||||
|
||||
// ============ 心跳 ============
|
||||
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.isConnected) {
|
||||
this._send({ ping: {} }, 5000).catch(() => {
|
||||
console.warn('[Nakama] Heartbeat failed');
|
||||
});
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 工具方法 ============
|
||||
|
||||
_base64Encode(str) {
|
||||
// 小程序环境没有 btoa,需要手动实现
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
let output = '';
|
||||
|
||||
// 将字符串转为 UTF-8 字节数组
|
||||
const bytes = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||
} else {
|
||||
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b1 = bytes[i];
|
||||
const b2 = bytes[i + 1];
|
||||
const b3 = bytes[i + 2];
|
||||
|
||||
output += chars.charAt(b1 >> 2);
|
||||
output += chars.charAt(((b1 & 3) << 4) | (b2 >> 4) || 0);
|
||||
output += b2 !== undefined ? chars.charAt(((b2 & 15) << 2) | (b3 >> 6) || 0) : '=';
|
||||
output += b3 !== undefined ? chars.charAt(b3 & 63) : '=';
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
_base64ToUint8Array(base64) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
const lookup = new Uint8Array(256);
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
lookup[chars.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
let bufferLength = base64.length * 0.75;
|
||||
if (base64[base64.length - 1] === '=') bufferLength--;
|
||||
if (base64[base64.length - 2] === '=') bufferLength--;
|
||||
|
||||
const bytes = new Uint8Array(bufferLength);
|
||||
let p = 0;
|
||||
|
||||
for (let i = 0; i < base64.length; i += 4) {
|
||||
const e1 = lookup[base64.charCodeAt(i)];
|
||||
const e2 = lookup[base64.charCodeAt(i + 1)];
|
||||
const e3 = lookup[base64.charCodeAt(i + 2)];
|
||||
const e4 = lookup[base64.charCodeAt(i + 3)];
|
||||
|
||||
bytes[p++] = (e1 << 2) | (e2 >> 4);
|
||||
if (base64[i + 2] !== '=') bytes[p++] = ((e2 & 15) << 4) | (e3 >> 2);
|
||||
if (base64[i + 3] !== '=') bytes[p++] = ((e3 & 3) << 6) | e4;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
_parseUserIdFromToken(token) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = parts[1];
|
||||
// Base64 URL 解码
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64 + '=='.slice(0, (4 - base64.length % 4) % 4);
|
||||
|
||||
// 解码 base64
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
const lookup = {};
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
lookup[chars[i]] = i;
|
||||
}
|
||||
|
||||
let bytes = [];
|
||||
for (let i = 0; i < padded.length; i += 4) {
|
||||
const e1 = lookup[padded[i]] || 0;
|
||||
const e2 = lookup[padded[i + 1]] || 0;
|
||||
const e3 = lookup[padded[i + 2]] || 0;
|
||||
const e4 = lookup[padded[i + 3]] || 0;
|
||||
|
||||
bytes.push((e1 << 2) | (e2 >> 4));
|
||||
if (padded[i + 2] !== '=') bytes.push(((e2 & 15) << 4) | (e3 >> 2));
|
||||
if (padded[i + 3] !== '=') bytes.push(((e3 & 3) << 6) | e4);
|
||||
}
|
||||
|
||||
// UTF-8 解码
|
||||
let str = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
str += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(str);
|
||||
return parsed.uid || parsed.sub || null;
|
||||
} catch (e) {
|
||||
console.error('[Nakama] Failed to parse token:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const nakamaManager = new NakamaManager();
|
||||
Loading…
x
Reference in New Issue
Block a user