This commit is contained in:
tsui110 2026-01-02 11:36:49 +08:00
commit 9c3775624f
14 changed files with 8261 additions and 152 deletions

View File

@ -292,3 +292,34 @@ export function getMatchingGameCards(game_id) {
data: { 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 } })
}

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

View File

@ -32,7 +32,30 @@
</view> </view>
<view class="popup-body"> <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="label">支付金额</text>
<text class="amount">¥{{ finalPayAmount }}</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> <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"> <view class="popup-footer">
<button class="btn-cancel" @tap="handleClose">取消</button> <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> </view>
</view> </view>
@ -102,7 +126,8 @@ const props = defineProps({
amount: { type: [Number, String], default: 0 }, amount: { type: [Number, String], default: 0 },
coupons: { type: Array, default: () => [] }, coupons: { type: Array, default: () => [] },
propCards: { 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']) const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
@ -174,6 +199,24 @@ watch(() => props.visible, (newVal) => {
const couponIndex = ref(-1) const couponIndex = ref(-1)
const cardIndex = 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(() => { const selectedCoupon = computed(() => {
if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) { if (couponIndex.value >= 0 && props.coupons[couponIndex.value]) {
@ -254,8 +297,9 @@ function handleClose() {
function handleConfirm() { function handleConfirm() {
emit('confirm', { emit('confirm', {
coupon: selectedCoupon.value, coupon: useGamePass.value ? null : selectedCoupon.value,
card: props.showCards ? selectedCard.value : null card: (props.showCards && !useGamePass.value) ? selectedCard.value : null,
useGamePass: useGamePass.value
}) })
} }
</script> </script>
@ -765,4 +809,129 @@ function handleConfirm() {
transform: scale(0.97); transform: scale(0.97);
box-shadow: $shadow-md; 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> </style>

View File

@ -68,6 +68,17 @@
<text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text> <text class="amount">{{ (Number(detail.price_draw || 0) / 100).toFixed(2) }}</text>
<text class="unit">/</text> <text class="unit">/</text>
</view> </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 v-if="hasResumeGame" class="action-btn secondary" @tap="onResumeGame">
继续游戏 继续游戏
</view> </view>
@ -163,8 +174,16 @@
:coupons="coupons" :coupons="coupons"
:propCards="propCards" :propCards="propCards"
:showCards="true" :showCards="true"
:gamePasses="gamePasses"
@confirm="onPaymentConfirm" @confirm="onPaymentConfirm"
/> />
<GamePassPurchasePopup
v-model:visible="purchasePopupVisible"
:activity-id="activityId"
@success="onPurchaseSuccess"
/>
<RulesPopup <RulesPopup
v-model:visible="rulesVisible" v-model:visible="rulesVisible"
:content="detail.gameplay_intro" :content="detail.gameplay_intro"
@ -189,6 +208,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import PaymentPopup from '../../../components/PaymentPopup.vue' import PaymentPopup from '../../../components/PaymentPopup.vue'
import GamePassPurchasePopup from '../../../components/GamePassPurchasePopup.vue'
import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue' import ActivityPageLayout from '@/components/activity/ActivityPageLayout.vue'
import ActivityHeader from '@/components/activity/ActivityHeader.vue' import ActivityHeader from '@/components/activity/ActivityHeader.vue'
import ActivityTabs from '@/components/activity/ActivityTabs.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 RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue' import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import LotteryResultPopup from '@/components/activity/LotteryResultPopup.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 { levelToAlpha } from '@/utils/activity'
import { vibrateShort } from '@/utils/vibrate.js' import { vibrateShort } from '@/utils/vibrate.js'
@ -226,6 +246,10 @@ const rulesVisible = ref(false)
const cabinetVisible = ref(false) const cabinetVisible = ref(false)
const resultVisible = ref(false) const resultVisible = ref(false)
const resultItems = ref([]) 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 resumeGame = ref(null)
const resumeIssueId = ref('') const resumeIssueId = ref('')
const hasResumeGame = computed(() => { const hasResumeGame = computed(() => {
@ -1317,6 +1341,7 @@ async function fetchCardTypes() {
async function onPaymentConfirm(data) { async function onPaymentConfirm(data) {
selectedCoupon.value = data?.coupon || null selectedCoupon.value = data?.coupon || null
selectedCard.value = data?.card || null selectedCard.value = data?.card || null
useGamePassFlag.value = data?.useGamePass || false
paymentVisible.value = false paymentVisible.value = false
await doDraw() await doDraw()
} }
@ -1336,12 +1361,13 @@ async function doDraw() {
return return
} }
// 1. createMatchingPreorder // 1. createMatchingPreorder
const preRes = await createMatchingPreorder({ const preRes = await createMatchingPreorder({
issue_id: Number(iid), issue_id: Number(iid),
position: String(selectedCardType.value.code || ''), position: String(selectedCardType.value.code || ''),
coupon_id: selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0, coupon_id: useGamePassFlag.value ? 0 : (selectedCoupon.value?.id ? Number(selectedCoupon.value.id) : 0),
item_card_id: selectedCard.value?.id ? Number(selectedCard.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('创建订单失败') 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 const gameId = preRes.game_id || preRes.data?.game_id || preRes.result?.game_id || preRes.gameId
if (!gameId) throw new Error('未获取到游戏ID') if (!gameId) throw new Error('未获取到游戏ID')
// 3. // 3.
uni.showLoading({ title: '拉起支付...' }) const payStatus = preRes.pay_status || preRes.data?.pay_status || 1
const payRes = await createWechatOrder({ openid, order_no: orderNo }) if (useGamePassFlag.value || payStatus === 2) {
await new Promise((resolve, reject) => { //
uni.requestPayment({ uni.showLoading({ title: '加载游戏...' })
provider: 'wxpay', //
timeStamp: payRes.timeStamp || payRes.timestamp, await fetchGamePasses()
nonceStr: payRes.nonceStr || payRes.noncestr, } else {
package: payRes.package, //
signType: payRes.signType || 'RSA', uni.showLoading({ title: '拉起支付...' })
paySign: payRes.paySign, const payRes = await createWechatOrder({ openid, order_no: orderNo })
success: resolve, await new Promise((resolve, reject) => {
fail: 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. // 4.
uni.showLoading({ title: '加载游戏...' })
const cardsRes = await getMatchingGameCards(gameId) const cardsRes = await getMatchingGameCards(gameId)
if (!cardsRes) throw new Error('获取游戏数据失败') if (!cardsRes) throw new Error('获取游戏数据失败')
@ -1385,7 +1420,7 @@ async function doDraw() {
}) })
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '支付成功', icon: 'success' }) uni.showToast({ title: useGamePassFlag.value ? '开始游戏' : '支付成功', icon: 'success' })
// 6. // 6.
syncResumeGame(aid) 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() { async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id') const user_id = uni.getStorageSync('user_id')
if (!user_id) return if (!user_id) return
@ -1478,6 +1533,8 @@ onLoad((opts) => {
syncResumeGame(id) syncResumeGame(id)
fetchDetail(id) fetchDetail(id)
fetchIssues(id) fetchIssues(id)
//
fetchGamePasses()
} }
fetchCardTypes() fetchCardTypes()
}) })
@ -2470,3 +2527,53 @@ onLoad((opts) => {
} }
} }
</style> </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>

View File

@ -1,32 +1,32 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 背景 --> <!-- 背景装饰 -->
<view class="bg-gradient"></view> <view class="bg-decoration"></view>
<!-- 头部 --> <!-- 头部 -->
<view class="header"> <view class="header">
<view class="back-btn" @tap="goBack"> <view class="back-btn" @tap="goBack">
<text class="back-icon"></text> <text class="back-icon"></text>
</view> </view>
<text class="title">🎮 动物扫雷大作战</text> <text class="title">动物扫雷大作战</text>
</view> </view>
<!-- 主内容 --> <!-- 主内容 -->
<view class="content"> <view class="content">
<!-- 游戏图标 --> <!-- 游戏图标 -->
<view class="game-icon-box"> <view class="game-icon-box fadeInUp">
<view class="game-icon">💣</view> <text class="game-icon">💣</text>
<view class="game-glow"></view> <view class="game-glow"></view>
</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-title">多人对战扫雷</text>
<text class="intro-desc">和好友一起挑战获胜赢取精美奖品</text> <text class="intro-desc">和好友一起挑战获胜赢取精美奖品</text>
</view> </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"> <view class="ticket-row">
<text class="ticket-label">我的游戏资格</text> <text class="ticket-label">我的游戏资格</text>
<view class="ticket-count-box"> <view class="ticket-count-box">
@ -34,7 +34,8 @@
<text class="ticket-unit"></text> <text class="ticket-unit"></text>
</view> </view>
</view> </view>
<text class="ticket-tip" v-if="ticketCount === 0">完成任务可获得游戏资格哦~</text> <view class="divider"></view>
<text class="ticket-tip">{{ ticketCount > 0 ? '每次进入消耗1次资格' : '完成任务可获得游戏资格哦~' }}</text>
</view> </view>
<!-- 加载中 --> <!-- 加载中 -->
@ -46,13 +47,12 @@
<!-- 底部按钮 --> <!-- 底部按钮 -->
<view class="footer"> <view class="footer">
<view <view
class="enter-btn" class="btn-primary"
:class="{ disabled: ticketCount <= 0 || entering }" :class="{ disabled: ticketCount <= 0 || entering }"
@tap="enterGame" @tap="enterGame"
> >
<text class="enter-btn-text">{{ entering ? '进入中...' : '进入游戏' }}</text> <text class="enter-btn-text">{{ entering ? '正在进入...' : (ticketCount > 0 ? '立即开局' : '资格不足') }}</text>
</view> </view>
<text class="footer-tip">每次进入消耗1次资格</text>
</view> </view>
</view> </view>
</template> </template>
@ -81,20 +81,14 @@ export default {
try { try {
const userInfo = uni.getStorageSync('user_info') || {} const userInfo = uni.getStorageSync('user_info') || {}
const userId = userInfo.id || userInfo.user_id const userId = userInfo.id || userInfo.user_id
console.log('===== 调试游戏资格 =====')
console.log('userId:', userId)
if (!userId) { if (!userId) {
console.log('未获取到用户ID返回0')
this.ticketCount = 0 this.ticketCount = 0
return return
} }
const res = await authRequest({ const res = await authRequest({
url: `/api/app/users/${userId}/game_tickets` url: `/api/app/users/${userId}/game_tickets`
}) })
console.log('API返回:', res)
// res : { minesweeper: 3, poker: 0, ... }
this.ticketCount = res[this.gameCode] || 0 this.ticketCount = res[this.gameCode] || 0
console.log('gameCode:', this.gameCode, 'ticketCount:', this.ticketCount)
} catch (e) { } catch (e) {
console.error('加载游戏资格失败', e) console.error('加载游戏资格失败', e)
this.ticketCount = 0 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 gameToken = encodeURIComponent(res.game_token)
const nakamaServer = encodeURIComponent(res.nakama_server) const nakamaServer = encodeURIComponent(res.nakama_server)
const nakamaKey = encodeURIComponent(res.nakama_key) const nakamaKey = encodeURIComponent(res.nakama_key)
const gameUrl = `${gameBaseUrl}?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
// webview
uni.navigateTo({ 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) { } catch (e) {
uni.showToast({ uni.showToast({
@ -133,7 +123,6 @@ export default {
}) })
} finally { } finally {
this.entering = false this.entering = false
//
this.loadTickets() this.loadTickets()
} }
} }
@ -142,47 +131,44 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import '@/uni.scss';
.page { .page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: $bg-page;
position: relative; position: relative;
overflow: hidden; 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 { .header {
position: relative; position: relative;
z-index: 1; z-index: 10;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 24rpx 32rpx; padding: 24rpx 32rpx;
padding-top: calc(100rpx + env(safe-area-inset-top)); padding-top: calc(80rpx + env(safe-area-inset-top));
} }
.back-btn { .back-btn {
width: 72rpx; width: 80rpx;
height: 72rpx; height: 80rpx;
background: rgba(255, 255, 255, 0.8); background: white;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .back-icon {
font-size: 36rpx; font-size: 40rpx;
color: #333; color: $text-main;
} }
.title { .title {
@ -190,71 +176,67 @@ export default {
text-align: center; text-align: center;
font-size: 36rpx; font-size: 36rpx;
font-weight: 800; font-weight: 800;
color: #333; color: $text-main;
margin-right: 72rpx; // margin-right: 80rpx;
} }
.content { .content {
flex: 1; flex: 1;
position: relative; position: relative;
z-index: 1; z-index: 5;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 40rpx 48rpx; padding: 40rpx 40rpx;
} }
.game-icon-box { .game-icon-box {
position: relative; position: relative;
margin-bottom: 48rpx; margin: 60rpx 0;
display: flex;
justify-content: center;
align-items: center;
} }
.game-icon { .game-icon {
font-size: 160rpx; font-size: 180rpx;
animation: bounce 2s ease-in-out infinite; animation: float 4s ease-in-out infinite;
z-index: 2;
} }
.game-glow { .game-glow {
position: absolute; position: absolute;
top: 50%; width: 280rpx;
left: 50%; height: 280rpx;
transform: translate(-50%, -50%); background: radial-gradient(circle, rgba($brand-primary, 0.25) 0%, transparent 70%);
width: 200rpx; filter: blur(20rpx);
height: 200rpx;
background: radial-gradient(circle, rgba(255,255,255,0.6) 0%, transparent 70%);
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
.intro-card { .intro-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 32rpx;
padding: 48rpx;
width: 100%; width: 100%;
padding: 48rpx;
text-align: center; text-align: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
margin-bottom: 32rpx; margin-bottom: 32rpx;
} }
.intro-title { .intro-title {
font-size: 40rpx; font-size: 44rpx;
font-weight: 800; font-weight: 900;
color: #333; color: $brand-primary;
display: block; display: block;
margin-bottom: 16rpx; margin-bottom: 16rpx;
} }
.intro-desc { .intro-desc {
font-size: 28rpx; font-size: 28rpx;
color: #666; color: $text-sub;
line-height: 1.5;
} }
.ticket-card { .ticket-card {
background: #fff;
border-radius: 24rpx;
padding: 32rpx 40rpx;
width: 100%; width: 100%;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08); padding: 40rpx;
} }
.ticket-row { .ticket-row {
@ -264,9 +246,9 @@ export default {
} }
.ticket-label { .ticket-label {
font-size: 30rpx; font-size: 32rpx;
color: #333; color: $text-main;
font-weight: 600; font-weight: 700;
} }
.ticket-count-box { .ticket-count-box {
@ -275,80 +257,64 @@ export default {
} }
.ticket-count { .ticket-count {
font-size: 56rpx; font-size: 64rpx;
font-weight: 900; font-weight: 900;
color: #7C3AED; color: $brand-primary;
margin-right: 8rpx;
} }
.ticket-unit { .ticket-unit {
font-size: 28rpx; font-size: 24rpx;
color: #666; color: $text-sub;
margin-left: 8rpx; }
.divider {
height: 1px;
background: rgba(0,0,0,0.05);
margin: 32rpx 0;
} }
.ticket-tip { .ticket-tip {
font-size: 24rpx; font-size: 24rpx;
color: #999; color: $text-sub;
margin-top: 16rpx;
display: block; display: block;
text-align: center;
} }
.loading-box { .loading-box {
padding: 60rpx; padding: 100rpx;
} }
.loading-text { .loading-text {
font-size: 28rpx; font-size: 28rpx;
color: #999; color: $text-sub;
} }
.footer { .footer {
position: relative; position: relative;
z-index: 1; z-index: 10;
padding: 32rpx 48rpx; padding: 40rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
} }
.enter-btn { .btn-primary {
background: linear-gradient(135deg, #7C3AED 0%, #9F7AEA 100%); height: 110rpx;
border-radius: 48rpx; width: 100%;
padding: 32rpx;
text-align: center; &.disabled {
box-shadow: 0 8rpx 24rpx rgba(124, 58, 237, 0.4); background: $text-disabled;
transition: all 0.2s; box-shadow: none;
} pointer-events: none;
}
.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;
} }
.enter-btn-text { .enter-btn-text {
font-size: 34rpx; font-size: 36rpx;
font-weight: 800; letter-spacing: 2rpx;
color: #fff;
} }
.footer-tip { /* Animations from App.vue are global, but we use local ones if needed */
display: block; .fadeInUp {
text-align: center; animation: fadeInUp 0.6s ease-out both;
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); }
} }
</style> </style>

File diff suppressed because it is too large Load Diff

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

View File

@ -43,6 +43,16 @@ function onMessage(e) {
data.forEach(msg => { data.forEach(msg => {
if (msg.action === 'close') { if (msg.action === 'close') {
uni.navigateBack() 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') { } else if (msg.action === 'game_over') {
// Optional: Refresh user balance or state // Optional: Refresh user balance or state
} }

View File

@ -139,6 +139,7 @@ const claiming = reactive({})
// () // ()
const userProgress = reactive({ const userProgress = reactive({
orderCount: 0, orderCount: 0,
orderAmount: 0,
inviteCount: 0, inviteCount: 0,
firstOrder: false, firstOrder: false,
claimedTiers: {} // { taskId: [tierId1, tierId2] } claimedTiers: {} // { taskId: [tierId1, tierId2] }
@ -224,6 +225,7 @@ function getTierBadge(tier) {
const metric = tier.metric || '' const metric = tier.metric || ''
if (metric === 'first_order') return '首' if (metric === 'first_order') return '首'
if (metric === 'order_count') return `${tier.threshold}` if (metric === 'order_count') return `${tier.threshold}`
if (metric === 'order_amount') return `¥${tier.threshold / 100}`
if (metric === 'invite_count') return `${tier.threshold}` if (metric === 'invite_count') return `${tier.threshold}`
return tier.threshold || '' return tier.threshold || ''
} }
@ -233,6 +235,7 @@ function getTierConditionText(tier) {
const metric = tier.metric || '' const metric = tier.metric || ''
if (metric === 'first_order') return '完成首笔订单' if (metric === 'first_order') return '完成首笔订单'
if (metric === 'order_count') return `累计下单 ${tier.threshold}` if (metric === 'order_count') return `累计下单 ${tier.threshold}`
if (metric === 'order_amount') return `累计消费 ¥${tier.threshold / 100}`
if (metric === 'invite_count') return `邀请 ${tier.threshold} 位好友` if (metric === 'invite_count') return `邀请 ${tier.threshold} 位好友`
return `达成 ${tier.threshold}` return `达成 ${tier.threshold}`
} }
@ -285,6 +288,8 @@ function isTierClaimable(task, tier) {
return userProgress.firstOrder return userProgress.firstOrder
} else if (metric === 'order_count') { } else if (metric === 'order_count') {
current = userProgress.orderCount || 0 current = userProgress.orderCount || 0
} else if (metric === 'order_amount') {
current = userProgress.orderAmount || 0
} else if (metric === 'invite_count') { } else if (metric === 'invite_count') {
current = userProgress.inviteCount || 0 current = userProgress.inviteCount || 0
} }
@ -305,6 +310,9 @@ function getTierProgressText(task, tier) {
return userProgress.firstOrder ? '已完成' : '未完成' return userProgress.firstOrder ? '已完成' : '未完成'
} else if (metric === 'order_count') { } else if (metric === 'order_count') {
current = userProgress.orderCount || 0 current = userProgress.orderCount || 0
} else if (metric === 'order_amount') {
current = userProgress.orderAmount || 0
return `¥${current / 100}${threshold / 100}`
} else if (metric === 'invite_count') { } else if (metric === 'invite_count') {
current = userProgress.inviteCount || 0 current = userProgress.inviteCount || 0
} }
@ -368,6 +376,7 @@ async function fetchData() {
try { try {
const progressRes = await getTaskProgress(list[0].id, userId) const progressRes = await getTaskProgress(list[0].id, userId)
userProgress.orderCount = progressRes.order_count || 0 userProgress.orderCount = progressRes.order_count || 0
userProgress.orderAmount = progressRes.order_amount || 0
userProgress.inviteCount = progressRes.invite_count || 0 userProgress.inviteCount = progressRes.invite_count || 0
userProgress.firstOrder = progressRes.first_order || false userProgress.firstOrder = progressRes.first_order || false

View File

@ -3,7 +3,8 @@
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": { "style": {
"navigationBarTitleText": "柯大鸭" "navigationBarTitleText": "柯大鸭",
"enablePullDownRefresh": true
} }
}, },
{ {
@ -177,6 +178,17 @@
"navigationBarTitleText": "扫雷 game" "navigationBarTitleText": "扫雷 game"
} }
}, },
{
"path": "game/minesweeper/play",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "扫雷对战",
"disableScroll": true,
"app-plus": {
"bounce": "none"
}
}
},
{ {
"path": "game/webview", "path": "game/webview",
"style": { "style": {

View File

@ -179,7 +179,14 @@ export default {
} }
}, },
onLoad() { onLoad() {
this.loadHomeData() // 200ms Token/Session
//
setTimeout(() => {
this.loadHomeData()
}, 200)
},
onPullDownRefresh() {
this.loadHomeData(true)
}, },
onShow() { onShow() {
// onLoad/onShow // onLoad/onShow
@ -255,24 +262,55 @@ export default {
const parts = [cat, price].filter(Boolean) const parts = [cat, price].filter(Boolean)
return parts.join(' · ') return parts.join(' · ')
}, },
async loadHomeData() { async loadHomeData(isRefresh = false) {
if (this.isHomeLoading) return if (this.isHomeLoading && !isRefresh) return
this.isHomeLoading = true 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 { try {
const [nData, bData, acData] = await Promise.all([ const [nData, bData, acData] = await Promise.all([
this.apiGet('/api/app/notices').catch(() => null), fetchWithRetry('/api/app/notices'),
this.apiGet('/api/app/banners').catch(() => null), fetchWithRetry('/api/app/banners'),
this.apiGet('/api/app/activities').catch(() => null) fetchWithRetry('/api/app/activities')
]) ])
if (nData) this.notices = this.normalizeNotices(nData) if (nData) this.notices = this.normalizeNotices(nData)
if (bData) this.banners = this.normalizeBanners(bData) 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) { } catch (e) {
console.error('Home data load failed', e) console.error('Home data load failed', e)
if (isRefresh) {
uni.showToast({ title: '刷新失败,请稍后重试', icon: 'none' })
}
} finally { } finally {
this.isHomeLoading = false this.isHomeLoading = false
if (isRefresh) {
uni.stopPullDownRefresh()
uni.showToast({ title: '刷新成功', icon: 'none' })
}
} }
}, },
onBannerTap(b) { onBannerTap(b) {

71
utils/nakama-adapter.js Normal file
View 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

File diff suppressed because it is too large Load Diff

468
utils/nakamaManager.js Normal file
View 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();